diff --git a/ansible/install.yml b/ansible/install.yml new file mode 100644 index 000000000..301b73bd7 --- /dev/null +++ b/ansible/install.yml @@ -0,0 +1,24 @@ +--- +- name: Ensure dependencies are installed + hosts: localhost + gather_facts: true + tags: + - kolla-ansible + - install + - bootstrap + vars: + kolla_ansible_install_epel: "{{ dnf_install_epel }}" + kolla_ansible_custom_requirements_search_paths_static: + - "{{ kayobe_config_path }}" + kolla_ansible_custom_requirements_search_paths: "{{ kolla_ansible_custom_requirements_search_paths_static + kayobe_env_search_paths | default([]) }}" + kolla_ansible_custom_requirements_paths: "{{ kolla_ansible_custom_requirements_search_paths | map('regex_replace', '$', '/kolla/requirements.yml') | list }}" + tasks: + - name: Install Kayobe dependencies + import_role: + name: bootstrap + tasks_from: install.yml + + - name: Install kolla-ansible + import_role: + name: kolla-ansible + tasks_from: install.yml diff --git a/ansible/kolla-ansible.yml b/ansible/kolla-ansible.yml index a02f96135..07514a63d 100644 --- a/ansible/kolla-ansible.yml +++ b/ansible/kolla-ansible.yml @@ -101,11 +101,6 @@ name: kolla-ansible vars: kolla_ansible_control_host_become: "{{ kayobe_control_host_become | bool }}" - kolla_ansible_install_epel: "{{ dnf_install_epel }}" - kolla_ansible_custom_requirements_search_paths_static: - - "{{ kayobe_config_path }}" - kolla_ansible_custom_requirements_search_paths: "{{ kolla_ansible_custom_requirements_search_paths_static + kayobe_env_search_paths | default([]) }}" - kolla_ansible_custom_requirements_paths: "{{ kolla_ansible_custom_requirements_search_paths | map('regex_replace', '$', '/kolla/requirements.yml') | list }}" kolla_external_fqdn_cert: "{{ kolla_config_path }}/certificates/haproxy.pem" kolla_internal_fqdn_cert: "{{ kolla_config_path }}/certificates/haproxy-internal.pem" kolla_ansible_passwords_path: "{{ kayobe_env_config_path }}/kolla/passwords.yml" diff --git a/ansible/roles/arista-switch/templates/arista-config.j2 b/ansible/roles/arista-switch/templates/arista-config.j2 index 2d9254f8a..ddd91b5bb 100644 --- a/ansible/roles/arista-switch/templates/arista-config.j2 +++ b/ansible/roles/arista-switch/templates/arista-config.j2 @@ -4,14 +4,12 @@ {{ line }} {% endfor %} -{% for interface, config in -arista_switch_interface_config.items() %} - interface {{ interface }} +{% for interface, config in arista_switch_interface_config.items() %} +interface {{ interface }} {% if config.description is defined %} - description {{ config.description }} + description {{ config.description }} {% endif %} {% for line in config.config %} - {{ line }} + {{ line }} {% endfor %} - exit {% endfor %} diff --git a/ansible/roles/bootstrap/tasks/install.yml b/ansible/roles/bootstrap/tasks/install.yml new file mode 100644 index 000000000..402960d31 --- /dev/null +++ b/ansible/roles/bootstrap/tasks/install.yml @@ -0,0 +1,46 @@ +--- +- block: + - name: Testing privilege escalation + raw: "true" + become: true + failed_when: false + changed_when: false + register: privilege_escalation_result + + - name: Assert that we can escalate privileges + assert: + that: + - privilege_escalation_result is success + - '"password is required" not in privilege_escalation_result.stderr' + fail_msg: >- + Could not escalate privileges. You can either: set kayobe_control_host_become: true, + set ansible_become_password, or set up passwordless sudo. + when: kayobe_control_host_become | bool + +- name: Include OS family-specific variables + include_vars: "{{ ansible_facts.os_family }}.yml" + +- name: Gather the package facts + ansible.builtin.package_facts: + manager: auto + +- block: + - name: Assert that all packages are installed if not using privilege escalation + assert: + that: missing_packages is falsy + fail_msg: >- + The following packages are missing from your system: {{ missing_packages | join(', ') }} and + privilege escalation is disabled. Please get your system administator to install these packages + or enable kayobe_control_host_become. + when: not kayobe_control_host_become | bool + + - name: Ensure required packages are installed + package: + name: "{{ bootstrap_package_dependencies }}" + state: present + cache_valid_time: "{{ apt_cache_valid_time if ansible_facts.os_family == 'Debian' else omit }}" + update_cache: "{{ True if ansible_facts.os_family == 'Debian' else omit }}" + become: True + when: missing_packages is truthy + vars: + missing_packages: "{{ bootstrap_package_dependencies | difference(ansible_facts.packages.keys()) }}" diff --git a/ansible/roles/bootstrap/tasks/main.yml b/ansible/roles/bootstrap/tasks/main.yml index ee673a204..802fb9889 100644 --- a/ansible/roles/bootstrap/tasks/main.yml +++ b/ansible/roles/bootstrap/tasks/main.yml @@ -1,50 +1,4 @@ --- -- block: - - name: Testing privilege escalation - raw: "true" - become: true - failed_when: false - changed_when: false - register: privilege_escalation_result - - - name: Assert that we can escalate privileges - assert: - that: - - privilege_escalation_result is success - - '"password is required" not in privilege_escalation_result.stderr' - fail_msg: >- - Could not escalate privileges. You can either: set kayobe_control_host_become: true, - set ansible_become_password, or set up passwordless sudo. - when: kayobe_control_host_become | bool - -- name: Include OS family-specific variables - include_vars: "{{ ansible_facts.os_family }}.yml" - -- name: Gather the package facts - ansible.builtin.package_facts: - manager: auto - -- block: - - name: Assert that all packages are installed if not using privilege escalation - assert: - that: missing_packages is falsy - fail_msg: >- - The following packages are missing from your system: {{ missing_packages | join(', ') }} and - privilege escalation is disabled. Please get your system administator to install these packages - or enable kayobe_control_host_become. - when: not kayobe_control_host_become | bool - - - name: Ensure required packages are installed - package: - name: "{{ bootstrap_package_dependencies }}" - state: present - cache_valid_time: "{{ apt_cache_valid_time if ansible_facts.os_family == 'Debian' else omit }}" - update_cache: "{{ True if ansible_facts.os_family == 'Debian' else omit }}" - become: True - when: missing_packages is truthy - vars: - missing_packages: "{{ bootstrap_package_dependencies | difference(ansible_facts.packages.keys()) }}" - - name: Check whether an SSH key exists stat: path: "{{ bootstrap_ssh_private_key_path }}" diff --git a/ansible/roles/kolla-ansible/tasks/install.yml b/ansible/roles/kolla-ansible/tasks/install.yml index 9408c3793..baaddd09d 100644 --- a/ansible/roles/kolla-ansible/tasks/install.yml +++ b/ansible/roles/kolla-ansible/tasks/install.yml @@ -44,7 +44,7 @@ package: name: "{{ packages }}" state: present - cache_valid_time: "{{ apt_cache_valid_time if ansible_facts.os_family == 'Debian' else omit }}" + cache_valid_time: "{{ apt_cache_valid_time | default(3600) if ansible_facts.os_family == 'Debian' else omit }}" update_cache: "{{ True if ansible_facts.os_family == 'Debian' else omit }}" become: True when: missing_packages is truthy diff --git a/ansible/roles/kolla-ansible/tasks/main.yml b/ansible/roles/kolla-ansible/tasks/main.yml index 031f38a97..1fc412a10 100644 --- a/ansible/roles/kolla-ansible/tasks/main.yml +++ b/ansible/roles/kolla-ansible/tasks/main.yml @@ -1,10 +1,4 @@ --- -# NOTE: Use import_tasks here, since tags are not applied to tasks included via -# include_tasks. -- import_tasks: install.yml - tags: - - install - - import_tasks: config.yml tags: - config diff --git a/ansible/roles/kolla-ansible/tests/test-defaults.yml b/ansible/roles/kolla-ansible/tests/test-defaults.yml index f5832c0d1..9e67db5b5 100644 --- a/ansible/roles/kolla-ansible/tests/test-defaults.yml +++ b/ansible/roles/kolla-ansible/tests/test-defaults.yml @@ -9,6 +9,17 @@ register: tempfile_result - block: + - name: Install dependencies + include_role: + name: "{{ playbook_dir }}/.." + tasks_from: install.yml + vars: + kolla_ansible_source_path: "{{ temp_path }}/src" + kolla_ansible_ctl_install_type: "source" + kolla_ansible_source_url: "http://github.com/openstack/kolla-ansible" + kolla_ansible_source_version: "{{ openstack_branch }}" + kolla_ansible_venv: "{{ temp_path }}/venv" + - name: Test the kolla-ansible role with default values include_role: name: "{{ playbook_dir }}/.." @@ -41,7 +52,6 @@ kolla_openstack_logging_debug: False kolla_globals_paths_extra: - "{{ tempfile_result.path ~ '/etc/kayobe/' }}" - apt_cache_valid_time: 3600 - name: Verify kolla-ansible installation shell: ". {{ temp_path }}/venv/bin/activate && kolla-ansible -h" diff --git a/ansible/roles/kolla-ansible/tests/test-extras.yml b/ansible/roles/kolla-ansible/tests/test-extras.yml index 83c3513ab..6b10ae794 100644 --- a/ansible/roles/kolla-ansible/tests/test-extras.yml +++ b/ansible/roles/kolla-ansible/tests/test-extras.yml @@ -81,6 +81,17 @@ bogus backend certificate - block: + - name: Install dependencies + include_role: + name: "{{ playbook_dir }}/.." + tasks_from: install.yml + vars: + kolla_ansible_source_path: "{{ temp_path }}/src" + kolla_ansible_ctl_install_type: "source" + kolla_ansible_source_url: "http://github.com/openstack/kolla-ansible" + kolla_ansible_source_version: "{{ openstack_branch }}" + kolla_ansible_venv: "{{ temp_path }}/venv" + - name: Test the kolla-ansible role with default values include_role: name: ../../kolla-ansible @@ -195,7 +206,6 @@ custom-password-1: "custom-password-1" custom-password-2: "custom-password-2" kolla_nova_compute_ironic_host: "controller1" - apt_cache_valid_time: 3600 - name: Verify kolla-ansible installation shell: ". {{ temp_path }}/venv/bin/activate && kolla-ansible -h" diff --git a/ansible/roles/kolla-ansible/tests/test-globals-merge.yml b/ansible/roles/kolla-ansible/tests/test-globals-merge.yml index 7613815cb..2529de994 100644 --- a/ansible/roles/kolla-ansible/tests/test-globals-merge.yml +++ b/ansible/roles/kolla-ansible/tests/test-globals-merge.yml @@ -9,6 +9,16 @@ register: tempfile_result - block: + - name: Install dependencies + include_role: + name: "{{ playbook_dir }}/.." + tasks_from: install.yml + vars: + kolla_ansible_source_path: "{{ temp_path }}/src" + kolla_ansible_ctl_install_type: "source" + kolla_ansible_source_url: "http://github.com/openstack/kolla-ansible" + kolla_ansible_source_version: "{{ openstack_branch }}" + kolla_ansible_venv: "{{ temp_path }}/venv" - name: Ensure directories exists file: @@ -74,7 +84,6 @@ - "{{ tempfile_result.path ~ '/etc/kayobe/' }}" - "{{ tempfile_result.path ~ '/etc/kayobe/environments/level1/' }}" - "{{ tempfile_result.path ~ '/etc/kayobe/environments/level2/' }}" - apt_cache_valid_time: 3600 - name: Verify kolla-ansible installation shell: ". {{ temp_path }}/venv/bin/activate && kolla-ansible -h" diff --git a/ansible/roles/kolla-ansible/tests/test-requirements.yml b/ansible/roles/kolla-ansible/tests/test-requirements.yml index 874a2ff9e..203b126e9 100644 --- a/ansible/roles/kolla-ansible/tests/test-requirements.yml +++ b/ansible/roles/kolla-ansible/tests/test-requirements.yml @@ -11,7 +11,8 @@ - block: - name: Test the kolla-ansible role with extra Python requirements include_role: - name: ../../kolla-ansible + name: "{{ playbook_dir }}/.." + tasks_from: install.yml vars: kolla_ansible_source_path: "{{ temp_path }}/src" kolla_ansible_ctl_install_type: "source" @@ -39,7 +40,6 @@ kolla_openstack_logging_debug: False kolla_globals_paths_extra: - "{{ tempfile_result.path ~ '/etc/kayobe/' }}" - apt_cache_valid_time: 3600 - name: List Python packages installed in virtualenv command: "{{ temp_path }}/venv/bin/pip list" diff --git a/doc/source/deployment.rst b/doc/source/deployment.rst index eb75d2127..f9d079291 100644 --- a/doc/source/deployment.rst +++ b/doc/source/deployment.rst @@ -29,6 +29,30 @@ To bootstrap the Ansible control host:: (kayobe) $ kayobe control host bootstrap +The bootstrap process now supports splitting dependency installation from +configuration. This is useful when building a container image for your +execution environment, where packages and Python dependencies are installed at +build time, and runtime-specific configuration is generated later. + +To install dependencies only (without running later bootstrap steps):: + + (kayobe) $ kayobe control host bootstrap --install-only + +To skip installation and run only later bootstrap steps:: + + (kayobe) $ kayobe control host bootstrap --no-install + +These modes are mutually exclusive. + +Environment variables may be used to set defaults: + +- ``KAYOBE_INSTALL_ONLY=true`` enables ``--install-only`` +- ``KAYOBE_NO_INSTALL=true`` enables ``--no-install`` + +For example, a container image build may run ``--install-only``, then at +container runtime execute ``--no-install`` to complete control host bootstrap +once environment-specific configuration is available. + Since the Gazpacho 20.0.0 release it is possible to manage the Ansible control host's configuration in the same way as other hosts. If using this feature, the Ansible control host should be added to the Kayobe inventory in the diff --git a/doc/source/support-matrix.rst b/doc/source/support-matrix.rst index a11503a91..9a5302554 100644 --- a/doc/source/support-matrix.rst +++ b/doc/source/support-matrix.rst @@ -25,7 +25,7 @@ therefore users need to build them by themselves. .. note:: Rocky Linux 9 is no longer supported as a host OS. The 2025.1 Epoxy release - supports both CentOS Stream 9 and 10 to provide a route for migration. + supports both Rocky Linux 9 and 10 to provide a route for migration. Supported container images ~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kayobe/cli/commands.py b/kayobe/cli/commands.py index 529d9b623..90b15e17c 100644 --- a/kayobe/cli/commands.py +++ b/kayobe/cli/commands.py @@ -18,6 +18,7 @@ import os import re import sys +import yaml from cliff.command import Command from cliff.hooks import CommandHook @@ -301,13 +302,48 @@ def get_parser(self, prog_name): group = parser.add_argument_group("Host Bootstrap") group.add_argument("--add-known-hosts", action='store_true', help="add SSH known hosts entries for each host") + install_group = group.add_mutually_exclusive_group() + install_group.add_argument( + "--no-install", + action='store_true', + default=yaml.safe_load(os.getenv("KAYOBE_NO_INSTALL", "false")), + help=("skip dependency installation and run later bootstrap " + "steps only (default from KAYOBE_NO_INSTALL env var)"), + ) + install_group.add_argument( + "--install-only", + action='store_true', + default=yaml.safe_load(os.getenv("KAYOBE_INSTALL_ONLY", "false")), + help=("only install dependencies " + "(default from KAYOBE_INSTALL_ONLY env var)"), + ) return parser def take_action(self, parsed_args): self.app.LOG.debug("Bootstrapping Kayobe Ansible control host") self.handle_kolla_tags_limits_deprecation(parsed_args) - ansible.install_galaxy_roles(parsed_args) - ansible.install_galaxy_collections(parsed_args) + + if parsed_args.install_only and parsed_args.no_install: + self.app.LOG.error("--install-only and --no-install are " + "mutually exclusive") + sys.exit(1) + + if parsed_args.no_install: + self.app.LOG.debug("Skipping dependency and Galaxy installation " + "due to --no-install") + else: + ansible.install_galaxy_roles(parsed_args) + ansible.install_galaxy_collections(parsed_args) + + playbooks = _build_playbook_list("install") + self.run_kayobe_playbooks(parsed_args, playbooks, + ignore_limit=True) + + if parsed_args.install_only: + self.app.LOG.debug("Skipping reset of bootstrap due to " + "--install-only") + return + playbooks = _build_playbook_list("bootstrap") self.run_kayobe_playbooks(parsed_args, playbooks, ignore_limit=True) @@ -455,11 +491,11 @@ def take_action(self, parsed_args): # Use force to upgrade roles and collections. ansible.install_galaxy_roles(parsed_args, force=True) ansible.install_galaxy_collections(parsed_args, force=True) + playbooks = _build_playbook_list("install") + self.run_kayobe_playbooks(parsed_args, playbooks, ignore_limit=True) + playbooks = _build_playbook_list("bootstrap") self.run_kayobe_playbooks(parsed_args, playbooks, ignore_limit=True) - playbooks = _build_playbook_list("kolla-ansible") - self.run_kayobe_playbooks(parsed_args, playbooks, tags="install", - ignore_limit=True, check=False) class ControlHostServiceDeploy(KayobeAnsibleMixin, VaultMixin, Command): diff --git a/kayobe/tests/unit/cli/test_commands.py b/kayobe/tests/unit/cli/test_commands.py index 171cf4678..8cda090b0 100644 --- a/kayobe/tests/unit/cli/test_commands.py +++ b/kayobe/tests/unit/cli/test_commands.py @@ -38,6 +38,54 @@ class TestCase(unittest.TestCase): maxDiff = None + @mock.patch.dict(os.environ, {}, clear=True) + def test_control_host_bootstrap_install_only_default_false(self): + command = commands.ControlHostBootstrap(TestApp(), []) + parser = command.get_parser("test") + parsed_args = parser.parse_args([]) + self.assertFalse(parsed_args.install_only) + + @mock.patch.dict(os.environ, {"KAYOBE_INSTALL_ONLY": "false"}, + clear=True) + def test_control_host_bootstrap_install_only_default_false_from_env(self): + command = commands.ControlHostBootstrap(TestApp(), []) + parser = command.get_parser("test") + parsed_args = parser.parse_args([]) + self.assertFalse(parsed_args.install_only) + + @mock.patch.dict(os.environ, {}, clear=True) + def test_control_host_bootstrap_no_install_default_false(self): + command = commands.ControlHostBootstrap(TestApp(), []) + parser = command.get_parser("test") + parsed_args = parser.parse_args([]) + self.assertFalse(parsed_args.no_install) + + @mock.patch.dict(os.environ, {"KAYOBE_NO_INSTALL": "false"}, + clear=True) + def test_control_host_bootstrap_no_install_default_false_from_env(self): + command = commands.ControlHostBootstrap(TestApp(), []) + parser = command.get_parser("test") + parsed_args = parser.parse_args([]) + self.assertFalse(parsed_args.no_install) + + @mock.patch.dict(os.environ, {"KAYOBE_NO_INSTALL": "true"}, + clear=True) + def test_control_host_bootstrap_no_install_default_true_from_env(self): + command = commands.ControlHostBootstrap(TestApp(), []) + parser = command.get_parser("test") + parsed_args = parser.parse_args([]) + self.assertTrue(parsed_args.no_install) + + def test_control_host_bootstrap_install_only_no_install_mutually_exclusive( + self): + command = commands.ControlHostBootstrap(TestApp(), []) + parser = command.get_parser("test") + self.assertRaises( + SystemExit, + parser.parse_args, + ["--install-only", "--no-install"], + ) + @mock.patch.object(ansible, "install_galaxy_roles", autospec=True) @mock.patch.object(ansible, "install_galaxy_collections", autospec=True) @mock.patch.object(ansible, "passwords_yml_exists", autospec=True) @@ -54,6 +102,44 @@ def test_control_host_bootstrap(self, mock_run, mock_passwords, self.assertEqual(0, result) mock_install_roles.assert_called_once_with(parsed_args) mock_install_collections.assert_called_once_with(parsed_args) + expected_calls = [ + mock.call( + mock.ANY, + [utils.get_data_files_path("ansible", "install.yml")], + ignore_limit=True, + ), + mock.call( + mock.ANY, + [utils.get_data_files_path("ansible", "bootstrap.yml")], + ignore_limit=True, + ), + mock.call( + mock.ANY, + [utils.get_data_files_path("ansible", "kolla-ansible.yml")], + tags="install", + ignore_limit=True, + check=False, + ), + ] + self.assertListEqual(expected_calls, mock_run.call_args_list) + + @mock.patch.object(ansible, "install_galaxy_roles", autospec=True) + @mock.patch.object(ansible, "install_galaxy_collections", autospec=True) + @mock.patch.object(ansible, "passwords_yml_exists", autospec=True) + @mock.patch.object(commands.KayobeAnsibleMixin, + "run_kayobe_playbooks") + def test_control_host_bootstrap_no_install(self, mock_run, + mock_passwords, + mock_install_collections, + mock_install_roles): + mock_passwords.return_value = False + command = commands.ControlHostBootstrap(TestApp(), []) + parser = command.get_parser("test") + parsed_args = parser.parse_args(["--no-install"]) + result = command.run(parsed_args) + self.assertEqual(0, result) + mock_install_roles.assert_not_called() + mock_install_collections.assert_not_called() expected_calls = [ mock.call( mock.ANY, @@ -70,6 +156,21 @@ def test_control_host_bootstrap(self, mock_run, mock_passwords, ] self.assertListEqual(expected_calls, mock_run.call_args_list) + @mock.patch.dict(os.environ, {"KAYOBE_INSTALL_ONLY": "true", + "KAYOBE_NO_INSTALL": "true"}, + clear=True) + @mock.patch.object(ansible, "install_galaxy_roles", autospec=True) + @mock.patch.object(ansible, "install_galaxy_collections", autospec=True) + def test_control_host_bootstrap_install_only_no_install_env_conflict( + self, mock_install_collections, mock_install_roles): + command = commands.ControlHostBootstrap(TestApp(), []) + parser = command.get_parser("test") + parsed_args = parser.parse_args([]) + + self.assertRaises(SystemExit, command.run, parsed_args) + mock_install_roles.assert_not_called() + mock_install_collections.assert_not_called() + @mock.patch.object(ansible, "install_galaxy_roles", autospec=True) @mock.patch.object(ansible, "install_galaxy_collections", autospec=True) @mock.patch.object(ansible, "passwords_yml_exists", autospec=True) @@ -89,6 +190,11 @@ def test_control_host_bootstrap_with_passwords( mock_install_roles.assert_called_once_with(parsed_args) mock_install_collections.assert_called_once_with(parsed_args) expected_calls = [ + mock.call( + mock.ANY, + [utils.get_data_files_path("ansible", "install.yml")], + ignore_limit=True, + ), mock.call( mock.ANY, [utils.get_data_files_path("ansible", "bootstrap.yml")], @@ -244,15 +350,13 @@ def test_control_host_upgrade(self, mock_run, mock_prune, expected_calls = [ mock.call( mock.ANY, - [utils.get_data_files_path("ansible", "bootstrap.yml")], + [utils.get_data_files_path("ansible", "install.yml")], ignore_limit=True, ), mock.call( mock.ANY, - [utils.get_data_files_path("ansible", "kolla-ansible.yml")], - tags="install", + [utils.get_data_files_path("ansible", "bootstrap.yml")], ignore_limit=True, - check=False, ), ] self.assertListEqual(expected_calls, mock_run.call_args_list) diff --git a/lint-requirements.txt b/lint-requirements.txt index 222ad5319..a645d87bf 100644 --- a/lint-requirements.txt +++ b/lint-requirements.txt @@ -1,4 +1,4 @@ -ansible-lint>=26.0.0,<27.0.0 # MIT +ansible-lint>=26.0.0,<26.8.0 # MIT bandit>=1.1.0 # Apache-2.0 bashate>=0.2 # Apache-2.0 doc8 # Apache-2.0 diff --git a/releasenotes/notes/adds-install-only-mode-17f545f434081020.yaml b/releasenotes/notes/adds-install-only-mode-17f545f434081020.yaml new file mode 100644 index 000000000..c90087bcb --- /dev/null +++ b/releasenotes/notes/adds-install-only-mode-17f545f434081020.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + Adds the ``--install-only`` and ``--no-install`` command line options to + ``kayobe control host bootstrap``. In ``install-only`` mode, only the + dependencies are installed, but the rest of the host configuration is + skipped. In ``no-install`` mode, install is skipped, but the rest of the + tasks are executed. diff --git a/releasenotes/notes/bug-2162130-872debd77abe5ff5.yaml b/releasenotes/notes/bug-2162130-872debd77abe5ff5.yaml new file mode 100644 index 000000000..fb2ec6241 --- /dev/null +++ b/releasenotes/notes/bug-2162130-872debd77abe5ff5.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + Resolves an issue that prevented Arista switch interface configuration from + being applied. + `LP#2162130 `__