From 9342f24936b9a30867a64bc2ac4d7fa171f67741 Mon Sep 17 00:00:00 2001 From: Pierre Riteau Date: Mon, 2 Mar 2026 14:29:08 +0100 Subject: [PATCH 01/11] Fix compatibility with python-ironicclient 6.0.0 Release 6.0.0 of python-ironicclient changed the way node attributes are named in JSON output [1]. We now need to access attributes using lower case. [1] https://review.opendev.org/c/openstack/python-ironicclient/+/973948 Depends-On: https://review.opendev.org/c/openstack/kolla-ansible/+/901100 Change-Id: Iccb99cfd1a723b3680b64781488d15e417642522 Signed-off-by: Pierre Riteau --- ansible/baremetal-compute-rename.yml | 6 ++--- ansible/baremetal-compute-serial-console.yml | 24 +++++++++---------- .../ipa-images/tasks/set-driver-info.yml | 14 +++++------ 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/ansible/baremetal-compute-rename.yml b/ansible/baremetal-compute-rename.yml index b2dd3330c..7a4ef1cdc 100644 --- a/ansible/baremetal-compute-rename.yml +++ b/ansible/baremetal-compute-rename.yml @@ -54,7 +54,7 @@ - name: Rename baremetal compute nodes command: > - {{ venv }}/bin/openstack baremetal node set --name "{{ inventory_hostname }}" "{{ node['UUID'] }}" + {{ venv }}/bin/openstack baremetal node set --name "{{ inventory_hostname }}" "{{ node['uuid'] }}" delegate_to: "{{ controller_host }}" environment: "{{ openstack_auth_env }}" vars: @@ -62,8 +62,8 @@ # be respected when using delegate_to. ansible_host: "{{ hostvars[controller_host].ansible_host | default(controller_host) }}" ipmi_address: "{{ hostvars[inventory_hostname].ipmi_address }}" - matching_nodes: "{{ (nodes.stdout | from_json) | selectattr('Driver Info.ipmi_address', 'defined') | selectattr('Driver Info.ipmi_address', 'equalto', ipmi_address) | list }}" + matching_nodes: "{{ (nodes.stdout | from_json) | selectattr('driver_info.ipmi_address', 'defined') | selectattr('driver_info.ipmi_address', 'equalto', ipmi_address) | list }}" node: "{{ matching_nodes | first }}" when: - matching_nodes | length > 0 - - node['Name'] != inventory_hostname + - node['name'] != inventory_hostname diff --git a/ansible/baremetal-compute-serial-console.yml b/ansible/baremetal-compute-serial-console.yml index 9de0ae976..cd987a485 100644 --- a/ansible/baremetal-compute-serial-console.yml +++ b/ansible/baremetal-compute-serial-console.yml @@ -79,11 +79,11 @@ fail: msg: >- In order to use the serial console you must set the console_interface to ipmitool-socat. - when: node["Console Interface"] != "ipmitool-socat" + when: node["console_interface"] != "ipmitool-socat" - name: Set IPMI serial console terminal port vars: - name: "{{ node['Name'] }}" + name: "{{ node['name'] }}" port: "{{ hostvars[controller_host].console_allocation_result.ports[name] }}" # NOTE: Without this, the controller's ansible_host variable will not # be respected when using delegate_to. @@ -93,8 +93,8 @@ delegate_to: "{{ controller_host }}" environment: "{{ openstack_auth_env }}" when: >- - node['Driver Info'].ipmi_terminal_port is not defined or - node['Driver Info'].ipmi_terminal_port | int != port | int + node['driver_info'].ipmi_terminal_port is not defined or + node['driver_info'].ipmi_terminal_port | int != port | int - name: Enable the IPMI socat serial console vars: @@ -102,14 +102,14 @@ # be respected when using delegate_to. ansible_host: "{{ hostvars[controller_host].ansible_host | default(controller_host) }}" command: > - {{ venv }}/bin/openstack baremetal node console enable {{ node['Name'] }} + {{ venv }}/bin/openstack baremetal node console enable {{ node['name'] }} delegate_to: "{{ controller_host }}" environment: "{{ openstack_auth_env }}" - when: not node['Console Enabled'] + when: not node['console_enabled'] vars: matching_nodes: >- - {{ (nodes.stdout | from_json) | selectattr('Name', 'defined') | - selectattr('Name', 'equalto', inventory_hostname) | list }} + {{ (nodes.stdout | from_json) | selectattr('name', 'defined') | + selectattr('name', 'equalto', inventory_hostname) | list }} node: "{{ matching_nodes | first }}" when: - cmd == "enable" @@ -122,14 +122,14 @@ # be respected when using delegate_to. ansible_host: "{{ hostvars[controller_host].ansible_host | default(controller_host) }}" command: > - {{ venv }}/bin/openstack baremetal node console disable {{ node['Name'] }} + {{ venv }}/bin/openstack baremetal node console disable {{ node['name'] }} delegate_to: "{{ controller_host }}" environment: "{{ openstack_auth_env }}" - when: node['Console Enabled'] + when: node['console_enabled'] vars: matching_nodes: >- - {{ (nodes.stdout | from_json) | selectattr('Name', 'defined') | - selectattr('Name', 'equalto', inventory_hostname) | list }} + {{ (nodes.stdout | from_json) | selectattr('name', 'defined') | + selectattr('name', 'equalto', inventory_hostname) | list }} node: "{{ matching_nodes | first }}" when: - cmd == "disable" diff --git a/ansible/roles/ipa-images/tasks/set-driver-info.yml b/ansible/roles/ipa-images/tasks/set-driver-info.yml index c2c11fcc9..6ddcbc89e 100644 --- a/ansible/roles/ipa-images/tasks/set-driver-info.yml +++ b/ansible/roles/ipa-images/tasks/set-driver-info.yml @@ -36,11 +36,11 @@ - name: Make sure openstack nodes are in baremetal-compute group add_host: - name: "{{ item.Name }}" + name: "{{ item.name }}" groups: baremetal-compute when: - - item.Name is not none - - item.Name not in groups["baremetal-compute"] + - item.name is not none + - item.name not in groups["baremetal-compute"] with_items: "{{ ipa_images_ironic_node_list.stdout | from_json }}" - name: Set fact containing filtered list of nodes @@ -55,15 +55,15 @@ set_fact: ipa_images_ironic_nodes: "{{ ipa_images_ironic_nodes + [item] }}" with_items: "{{ ipa_images_ironic_node_list.stdout | from_json }}" - when: item['Name'] in ipa_images_compute_node_whitelist + when: item['name'] in ipa_images_compute_node_whitelist - name: Ensure ironic nodes use the new Ironic Python Agent (IPA) images command: > - {{ ipa_images_venv }}/bin/openstack baremetal node set {{ item.UUID }} + {{ ipa_images_venv }}/bin/openstack baremetal node set {{ item.uuid }} --driver-info deploy_kernel={{ ipa_images_kernel_uuid }} --driver-info deploy_ramdisk={{ ipa_images_ramdisk_uuid }} with_items: "{{ ipa_images_ironic_nodes }}" when: - item["Driver Info"].deploy_kernel != ipa_images_kernel_uuid or - item["Driver Info"].deploy_ramdisk != ipa_images_ramdisk_uuid + item["driver_info"].deploy_kernel != ipa_images_kernel_uuid or + item["driver_info"].deploy_ramdisk != ipa_images_ramdisk_uuid environment: "{{ ipa_images_ironic_openstack_auth_env }}" From 505da7a072fc7688fa7afd9a0ec68d449e1366c1 Mon Sep 17 00:00:00 2001 From: Matt Crees Date: Tue, 3 Mar 2026 11:12:25 +0000 Subject: [PATCH 02/11] Drop backwards compatibility for template trusting With the G cycle, this feature is always supported. Change-Id: I0ccbfd7baa2ae43f06a91f34d8357d91e57317ee Signed-off-by: Matt Crees --- kayobe/plugins/action/kolla_ansible_host_vars.py | 11 +---------- kayobe/plugins/action/merge_configs.py | 10 +--------- kayobe/plugins/action/merge_yaml.py | 10 +--------- 3 files changed, 3 insertions(+), 28 deletions(-) diff --git a/kayobe/plugins/action/kolla_ansible_host_vars.py b/kayobe/plugins/action/kolla_ansible_host_vars.py index d6b620980..085e60004 100644 --- a/kayobe/plugins/action/kolla_ansible_host_vars.py +++ b/kayobe/plugins/action/kolla_ansible_host_vars.py @@ -13,16 +13,7 @@ # under the License. from ansible.plugins.action import ActionBase - -# TODO(dougszu): From Ansible 12 onwards we must explicitly trust templates. -# Since this feature is not supported in previous releases, we define a -# noop method here for backwards compatibility. This can be removed in the -# G cycle. -try: - from ansible.template import trust_as_template -except ImportError: - def trust_as_template(template): - return template +from ansible.template import trust_as_template class ConfigError(Exception): diff --git a/kayobe/plugins/action/merge_configs.py b/kayobe/plugins/action/merge_configs.py index d1c22e25a..605414bfc 100644 --- a/kayobe/plugins/action/merge_configs.py +++ b/kayobe/plugins/action/merge_configs.py @@ -24,15 +24,7 @@ from ansible import constants from ansible.plugins import action -# TODO(dougszu): From Ansible 12 onwards we must explicitly trust templates. -# Since this feature is not supported in previous releases, we define a -# noop method here for backwards compatibility. This can be removed in the -# G cycle. -try: - from ansible.template import trust_as_template -except ImportError: - def trust_as_template(template): - return template +from ansible.template import trust_as_template from io import StringIO diff --git a/kayobe/plugins/action/merge_yaml.py b/kayobe/plugins/action/merge_yaml.py index 41ab5f099..35d250371 100644 --- a/kayobe/plugins/action/merge_yaml.py +++ b/kayobe/plugins/action/merge_yaml.py @@ -27,15 +27,7 @@ from ansible import errors as ansible_errors from ansible.plugins import action -# TODO(dougszu): From Ansible 12 onwards we must explicitly trust templates. -# Since this feature is not supported in previous releases, we define a -# noop method here for backwards compatibility. This can be removed in the -# G cycle. -try: - from ansible.template import trust_as_template -except ImportError: - def trust_as_template(template): - return template +from ansible.template import trust_as_template DOCUMENTATION = ''' --- From a46f6484f8b361c322533bfe60b6cfe12aeca85a Mon Sep 17 00:00:00 2001 From: Will Szumski Date: Thu, 29 Jan 2026 16:00:32 +0000 Subject: [PATCH 03/11] [networkd] Fix broken conditional [DEPRECATION WARNING]: Conditional result (False) was derived from value of type 'int' at '/home/ubuntu/kayobe/ansible/roles/network-debian/tasks/main.yml:51:9'. Conditionals must have a boolean result. This feature will be removed from ansible-core version 2.23. Origin: /home/ubuntu/kayobe/ansible/roles/network-debian/tasks/main.yml:51:9 49 command: "udevadm trigger --verbose --subsystem-match=net --action=add" 50 changed_when: false 51 when: network_interfaces | networkd_links | length ^ column 9 Broken conditionals are currently allowed because the `ALLOW_BROKEN_CONDITIONALS` configuration option is enabled. TrivialFix Change-Id: I3e04902ac1cf129d325d291cafb719fd15a84368 Signed-off-by: Will Szumski --- ansible/roles/network-debian/tasks/main.yml | 2 +- requirements.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ansible/roles/network-debian/tasks/main.yml b/ansible/roles/network-debian/tasks/main.yml index 27091a841..c1c75ea53 100644 --- a/ansible/roles/network-debian/tasks/main.yml +++ b/ansible/roles/network-debian/tasks/main.yml @@ -48,4 +48,4 @@ become: true command: "udevadm trigger --verbose --subsystem-match=net --action=add" changed_when: false - when: network_interfaces | networkd_links | length + when: network_interfaces | networkd_links | length > 0 diff --git a/requirements.yml b/requirements.yml index e0aa67977..81a221af9 100644 --- a/requirements.yml +++ b/requirements.yml @@ -18,7 +18,7 @@ collections: - name: openstack.cloud version: '<3' - name: stackhpc.linux - version: 1.5.1 + version: 1.5.2 - name: stackhpc.network version: 1.0.0 - name: stackhpc.openstack From 190b79b5d02a67be5353b5e908f6f3aa909aac49 Mon Sep 17 00:00:00 2001 From: Will Szumski Date: Thu, 29 Jan 2026 17:36:21 +0000 Subject: [PATCH 04/11] [firewalld] Fix broken conditional Broken conditionals are currently allowed because the `ALLOW_BROKEN_CONDITIONALS` configuration option is enabled. [DEPRECATION WARNING]: Conditional result (True) was derived from value of type 'str' at '/home/zuul/src/opendev.org/openstack/kayobe-config-dev/etc/kayobe/zz-30-overrides.yml:47:25'. Conditionals must have a boolean result. This feature will be removed from ansible-core version 2.23. Origin: /home/zuul/kayobe-venv/share/kayobe/ansible/roles/firewalld/tasks/enabled.yml:50:9 48 become: true 49 loop: "{{ network_interfaces }}" 50 when: item | net_zone ^ column 9 TrivialFix Change-Id: I88e0f12e838070196d8f3ffa4ec95464ab323632 Signed-off-by: Will Szumski --- ansible/roles/firewalld/tasks/enabled.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/roles/firewalld/tasks/enabled.yml b/ansible/roles/firewalld/tasks/enabled.yml index 048645169..1ab86bcd6 100644 --- a/ansible/roles/firewalld/tasks/enabled.yml +++ b/ansible/roles/firewalld/tasks/enabled.yml @@ -47,7 +47,7 @@ zone: "{{ item | net_zone }}" become: true loop: "{{ network_interfaces }}" - when: item | net_zone + when: item | net_zone is truthy notify: Restart firewalld - name: Ensure firewalld rules are applied From fa4f0be487f5a6979964c910f47581529cc6cf66 Mon Sep 17 00:00:00 2001 From: Pierre Riteau Date: Wed, 4 Mar 2026 08:33:34 +0100 Subject: [PATCH 05/11] Revert "CI: Disable seed jobs" This reverts commit aa230f9d05eea695f2aaa09a348f56a2079e6eda. Reason for revert: Bifrost passlib fix is merged. Change-Id: I5b179291a335cb32c782cf351554fdd6bd3e144f Signed-off-by: Pierre Riteau --- zuul.d/project.yaml | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml index c040a3d15..37fe7338e 100644 --- a/zuul.d/project.yaml +++ b/zuul.d/project.yaml @@ -28,14 +28,14 @@ - kayobe-overcloud-upgrade-slurp-rocky10 - kayobe-overcloud-upgrade-slurp-ubuntu-noble - kayobe-overcloud-upgrade-ubuntu-noble - #- kayobe-seed-rocky10 - #- kayobe-seed-rocky10-podman - #- kayobe-seed-ubuntu-noble - #- kayobe-seed-ubuntu-noble-podman - #- kayobe-seed-upgrade-rocky10 - #- kayobe-seed-upgrade-slurp-rocky10 - #- kayobe-seed-upgrade-slurp-ubuntu-noble - #- kayobe-seed-upgrade-ubuntu-noble + - kayobe-seed-rocky10 + - kayobe-seed-rocky10-podman + - kayobe-seed-ubuntu-noble + - kayobe-seed-ubuntu-noble-podman + - kayobe-seed-upgrade-rocky10 + - kayobe-seed-upgrade-slurp-rocky10 + - kayobe-seed-upgrade-slurp-ubuntu-noble + - kayobe-seed-upgrade-ubuntu-noble - kayobe-seed-vm-rocky10 - kayobe-seed-vm-ubuntu-noble gate: @@ -59,14 +59,14 @@ - kayobe-overcloud-upgrade-slurp-rocky10 - kayobe-overcloud-upgrade-slurp-ubuntu-noble - kayobe-overcloud-upgrade-ubuntu-noble - #- kayobe-seed-rocky10 - #- kayobe-seed-rocky10-podman - #- kayobe-seed-ubuntu-noble - #- kayobe-seed-ubuntu-noble-podman - #- kayobe-seed-upgrade-rocky10 - #- kayobe-seed-upgrade-slurp-rocky10 - #- kayobe-seed-upgrade-slurp-ubuntu-noble - #- kayobe-seed-upgrade-ubuntu-noble + - kayobe-seed-rocky10 + - kayobe-seed-rocky10-podman + - kayobe-seed-ubuntu-noble + - kayobe-seed-ubuntu-noble-podman + - kayobe-seed-upgrade-rocky10 + - kayobe-seed-upgrade-slurp-rocky10 + - kayobe-seed-upgrade-slurp-ubuntu-noble + - kayobe-seed-upgrade-ubuntu-noble - kayobe-seed-vm-rocky10 - kayobe-seed-vm-ubuntu-noble From 79bee73dcb9fe2b07157c98ef4a6cf812ac9250a Mon Sep 17 00:00:00 2001 From: Will Szumski Date: Mon, 16 Feb 2026 15:04:46 +0000 Subject: [PATCH 06/11] Bump stackhpc.openstack to 0.10.1 This prevents us needing the CRB and EPEL repositories to be enabled on Rocky hosts when building DIB images. Closes-Bug: #2141684 Closes-Bug: #2142501 Change-Id: Id3e610ad466212d3b8dde7a429ea66cc1562b047 Signed-off-by: Will Szumski --- .../notes/fixes-dib-image-build-18f29d072b913669.yaml | 10 ++++++++++ requirements.yml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 releasenotes/notes/fixes-dib-image-build-18f29d072b913669.yaml diff --git a/releasenotes/notes/fixes-dib-image-build-18f29d072b913669.yaml b/releasenotes/notes/fixes-dib-image-build-18f29d072b913669.yaml new file mode 100644 index 000000000..4639a4add --- /dev/null +++ b/releasenotes/notes/fixes-dib-image-build-18f29d072b913669.yaml @@ -0,0 +1,10 @@ +--- +fixes: + - | + Fixes an issue building diskimage-builder images when EPEL is disabled. + See `LP#2141684 `_ for more + details. + - | + Fixes an issue building diskimage-builder images when using the podman + container engine, See `LP#2142501 + `_ for more details. diff --git a/requirements.yml b/requirements.yml index e0aa67977..aad1416ee 100644 --- a/requirements.yml +++ b/requirements.yml @@ -22,7 +22,7 @@ collections: - name: stackhpc.network version: 1.0.0 - name: stackhpc.openstack - version: 0.9.0 + version: 0.10.1 roles: - src: ahuffman.resolv From 8767fcc69281277139f7132f36a2a65f8a06f5e8 Mon Sep 17 00:00:00 2001 From: Hollie Hutchinson Date: Fri, 28 Nov 2025 11:36:11 +0000 Subject: [PATCH 07/11] Skip external connectivity check when behind a proxy Network connectivity check fails for hosts that have no external network, so this check is now skipped if ``http_proxy`` is defined. Change-Id: Ib6f815c319a7e92e675382cfe9d4011598e72aba Signed-off-by: Hollie Hutchinson --- ansible/network-connectivity.yml | 40 ++++++++++--------- etc/kayobe/networks.yml | 3 ++ ...l-connectivity-check-43d232b52f43ed93.yaml | 4 ++ 3 files changed, 29 insertions(+), 18 deletions(-) create mode 100644 releasenotes/notes/fix-external-connectivity-check-43d232b52f43ed93.yaml diff --git a/ansible/network-connectivity.yml b/ansible/network-connectivity.yml index 3e0238d8c..bfcca9f9e 100644 --- a/ansible/network-connectivity.yml +++ b/ansible/network-connectivity.yml @@ -6,6 +6,8 @@ default(kayobe_max_fail_percentage) | default(100) }} vars: + # Skip external connectivity check when behind a proxy. + nc_skip_external_net: "{{ http_proxy | truthy }}" # Set this to an external IP address to check. nc_external_ip: 8.8.8.8 # Set this to an external hostname to check. @@ -14,27 +16,29 @@ # (20 bytes) headers. icmp_overhead_bytes: 28 tasks: - - name: "Display next action: external IP address check" - debug: - msg: > - Checking whether hosts have access to an external IP address, - {{ nc_external_ip }}. - run_once: True + - block: + - name: "Display next action: external IP address check" + debug: + msg: > + Checking whether hosts have access to an external IP address, + {{ nc_external_ip }}. + run_once: True - - name: Ensure an external IP is reachable - command: ping -c1 {{ nc_external_ip }} - changed_when: False + - name: Ensure an external IP is reachable + command: ping -c1 {{ nc_external_ip }} + changed_when: False - - name: "Display next action: external hostname check" - debug: - msg: > - Checking whether hosts have access to an external hostname, - {{ nc_external_hostname }}. - run_once: True + - name: "Display next action: external hostname check" + debug: + msg: > + Checking whether hosts have access to an external hostname, + {{ nc_external_hostname }}. + run_once: True - - name: Ensure an external host is reachable - command: ping -c1 {{ nc_external_hostname }} - changed_when: False + - name: Ensure an external host is reachable + command: ping -c1 {{ nc_external_hostname }} + changed_when: False + when: not nc_skip_external_net - name: "Display next action: gateway check" debug: diff --git a/etc/kayobe/networks.yml b/etc/kayobe/networks.yml index 17c9028c4..2132fd179 100644 --- a/etc/kayobe/networks.yml +++ b/etc/kayobe/networks.yml @@ -106,6 +106,9 @@ ############################################################################### # Network connectivity check configuration. +# Whether to skip the external network connectivity check. Default is false. +#nc_skip_external_net: + # External IP address to check. Default is 8.8.8.8. #nc_external_ip: diff --git a/releasenotes/notes/fix-external-connectivity-check-43d232b52f43ed93.yaml b/releasenotes/notes/fix-external-connectivity-check-43d232b52f43ed93.yaml new file mode 100644 index 000000000..3ee7d838f --- /dev/null +++ b/releasenotes/notes/fix-external-connectivity-check-43d232b52f43ed93.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Skip external connectivity check behind a proxy. From fb1767ad7f42235f576ec5ae3fd6f623f207e512 Mon Sep 17 00:00:00 2001 From: Matt Crees Date: Tue, 19 Nov 2024 16:09:13 +0000 Subject: [PATCH 08/11] Deprecate kolla-tags and kolla-limit Deprecates the options ``--kolla-tags``, ``--kolla-skip-tags``, and ``kolla-limit``. Regular ``--tags``, ``--skip-tags``, and ``--limit`` will now be passed directly to the Kolla-Ansible invocations. The ``kayobe-generate-config`` tag is added to ``kolla-ansible.yml`` and ``kolla-openstack.yml``. It is always called, to allow for limiting to OpenStack services with just one tag, e.g. ``kayobe overcloud service deploy -t nova`. You can still skip this with ``--skip-tags kayobe-generate-config``. Also adds the ``bifrost`` tag to ``kolla-bifrost.yml``, so that we can easily limit to bifrost in the seed service deploy. As there is no clean way to handle some of Ansible's "special" tags like ``none``, using both regular and kolla tags/limits together is no longer allowed. Change-Id: I6f466305d49031da4d048f8fa7d2625b261a6fa0 Signed-off-by: Matt Crees Co-Authored-By: Will Szumski --- ansible/kayobe-target-venv.yml | 4 +- ansible/kolla-ansible.yml | 4 + ansible/kolla-bifrost.yml | 1 + ansible/kolla-openstack.yml | 3 + doc/source/administration/overcloud.rst | 29 +++---- doc/source/administration/seed.rst | 7 +- doc/source/configuration/reference/vgpu.rst | 4 +- doc/source/deployment.rst | 7 +- doc/source/upgrading.rst | 10 ++- doc/source/usage.rst | 22 ++--- kayobe/ansible.py | 2 + kayobe/cli/commands.py | 85 ++++++++++++++++--- kayobe/cmd/kayobe.py | 33 +++++++ kayobe/kolla_ansible.py | 28 +++--- kayobe/tests/unit/test_ansible.py | 6 +- kayobe/tests/unit/test_kolla_ansible.py | 12 +-- ...ags-and-kolla-limits-254faef5584176e1.yaml | 22 +++++ 17 files changed, 209 insertions(+), 70 deletions(-) create mode 100644 releasenotes/notes/drop-kolla-tags-and-kolla-limits-254faef5584176e1.yaml diff --git a/ansible/kayobe-target-venv.yml b/ansible/kayobe-target-venv.yml index 53005c86e..4d4ae092f 100644 --- a/ansible/kayobe-target-venv.yml +++ b/ansible/kayobe-target-venv.yml @@ -26,7 +26,9 @@ filter: "{{ kayobe_ansible_setup_filter }}" gather_subset: "{{ kayobe_ansible_setup_gather_subset }}" when: - - ansible_facts is undefined or ansible_facts is falsy + #TODO(mattcrees): Enable this check once this bug is fixed: + # https://bugs.launchpad.net/kayobe/+bug/2144548 + # - ansible_facts is undefined or ansible_facts is falsy - kayobe_virtualenv is defined register: gather_facts_result # Before any facts are gathered, ansible doesn't know about diff --git a/ansible/kolla-ansible.yml b/ansible/kolla-ansible.yml index ec6c4e173..5a0505e52 100644 --- a/ansible/kolla-ansible.yml +++ b/ansible/kolla-ansible.yml @@ -9,6 +9,7 @@ tags: - kolla-ansible - config-validation + - kayobe-generate-config tasks: - name: Validate serial console configuration block: @@ -26,6 +27,7 @@ hosts: localhost tags: - kolla-ansible + - kayobe-generate-config gather_facts: false pre_tasks: - block: @@ -113,6 +115,7 @@ - config - config-validation - kolla-ansible + - kayobe-generate-config gather_facts: False tasks: - name: Set Kolla Ansible host variables @@ -142,6 +145,7 @@ - config - config-validation - kolla-ansible + - kayobe-generate-config gather_facts: False tasks: - name: Set Kolla Ansible host variables diff --git a/ansible/kolla-bifrost.yml b/ansible/kolla-bifrost.yml index 87023deb5..88b7b4a1b 100644 --- a/ansible/kolla-bifrost.yml +++ b/ansible/kolla-bifrost.yml @@ -3,6 +3,7 @@ hosts: localhost tags: - kolla-bifrost + - bifrost roles: - role: kolla-bifrost diff --git a/ansible/kolla-openstack.yml b/ansible/kolla-openstack.yml index ea2aec967..a2c1152f0 100644 --- a/ansible/kolla-openstack.yml +++ b/ansible/kolla-openstack.yml @@ -7,6 +7,7 @@ - config-validation - kolla-ansible - kolla-openstack + - kayobe-generate-config tasks: - name: Create controllers group with ironic enabled group_by: @@ -19,6 +20,7 @@ tags: - kolla-ansible - kolla-openstack + - kayobe-generate-config vars: # These are the filenames generated by overcloud-ipa-build.yml. ipa_image_name: "ipa" @@ -57,6 +59,7 @@ tags: - kolla-ansible - kolla-openstack + - kayobe-generate-config vars: switch_type_to_device_type: arista: netmiko_arista_eos diff --git a/doc/source/administration/overcloud.rst b/doc/source/administration/overcloud.rst index 13080a33f..f27187d22 100644 --- a/doc/source/administration/overcloud.rst +++ b/doc/source/administration/overcloud.rst @@ -15,7 +15,7 @@ necessary to update these prior to running a package update. To do this, update the configuration in ``${KAYOBE_CONFIG_PATH}/dnf.yml`` and run the following command:: - (kayobe) $ kayobe overcloud host configure --tags dnf --kolla-tags none + (kayobe) $ kayobe overcloud host configure --tags dnf Package Update -------------- @@ -80,10 +80,9 @@ improved by specifying Ansible tags to limit the tasks run in kayobe and/or kolla-ansible's playbooks. This may require knowledge of the inner workings of these tools but in general, kolla-ansible tags the play used to configure each service by the name of that service. For example: ``nova``, ``neutron`` or -``ironic``. Use ``-t`` or ``--tags`` to specify kayobe tags and ``-kt`` or -``--kolla-tags`` to specify kolla-ansible tags. For example:: +``ironic``. Use ``-t`` or ``--tags`` to specify tags. For example:: - (kayobe) $ kayobe overcloud service reconfigure --tags config --kolla-tags nova,ironic + (kayobe) $ kayobe overcloud service reconfigure --tags nova,ironic Deploying Updated Container Images ================================== @@ -105,10 +104,9 @@ improved by specifying Ansible tags to limit the tasks run in kayobe and/or kolla-ansible's playbooks. This may require knowledge of the inner workings of these tools but in general, kolla-ansible tags the play used to configure each service by the name of that service. For example: ``nova``, ``neutron`` or -``ironic``. Use ``-t`` or ``--tags`` to specify kayobe tags and ``-kt`` or -``--kolla-tags`` to specify kolla-ansible tags. For example:: +``ironic``. Use ``-t`` or ``--tags`` to specify tags. For example:: - (kayobe) $ kayobe overcloud service deploy containers --kolla-tags nova,ironic + (kayobe) $ kayobe overcloud service deploy containers --tags nova,ironic Upgrading Containerised Services ================================ @@ -126,9 +124,9 @@ To upgrade the containerised control plane services:: (kayobe) $ kayobe overcloud service upgrade As for the reconfiguration command, it is possible to specify tags for Kayobe -and/or kolla-ansible:: +and kolla-ansible:: - (kayobe) $ kayobe overcloud service upgrade --tags config --kolla-tags keystone + (kayobe) $ kayobe overcloud service upgrade --tags keystone Running Prechecks ================= @@ -137,10 +135,10 @@ Sometimes it may be useful to run prechecks without deploying services:: (kayobe) $ kayobe overcloud service prechecks -As for other similar commands, it is possible to specify tags for Kayobe and/or +As for other similar commands, it is possible to specify tags for Kayobe and kolla-ansible:: - (kayobe) $ kayobe overcloud service upgrade --tags config --kolla-tags keystone + (kayobe) $ kayobe overcloud service upgrade --tags keystone Stopping the Overcloud Services =============================== @@ -156,12 +154,11 @@ To stop the overcloud services:: It should be noted that this state is persistent - containers will remain stopped after a reboot of the host on which they are running. -It is possible to limit the operation to particular hosts via -``--kolla-limit``, or to particular services via ``--kolla-tags``. It is also -possible to avoid stopping the common containers via ``--kolla-skip-tags -common``. For example: +It is possible to limit the operation to particular hosts via ``--limit``, or +to particular services via ``--tags``. It is also possible to avoid stopping +the common containers via ``--skip-tags common``. For example: - (kayobe) $ kayobe overcloud service stop --kolla-tags glance,nova --kolla-skip-tags common + (kayobe) $ kayobe overcloud service stop --tags glance,nova --skip-tags common Destroying the Overcloud Services ================================= diff --git a/doc/source/administration/seed.rst b/doc/source/administration/seed.rst index b0bf5d0d7..0e2f8ba35 100644 --- a/doc/source/administration/seed.rst +++ b/doc/source/administration/seed.rst @@ -31,10 +31,7 @@ To destroy the seed services:: This can optionally be used with a tag:: - (kayobe) $ kayobe seed service destroy --yes-i-really-really-mean-it -kt none -t docker-registry - -Care must be taken to set both kayobe and kolla tags to avoid accidentally -destroying other services. + (kayobe) $ kayobe seed service destroy --yes-i-really-really-mean-it -t docker-registry Updating Packages ================= @@ -49,7 +46,7 @@ necessary to update these prior to running a package update. To do this, update the configuration in ``${KAYOBE_CONFIG_PATH}/dnf.yml`` and run the following command:: - (kayobe) $ kayobe seed host configure --tags dnf --kolla-tags none + (kayobe) $ kayobe seed host configure --tags dnf Package Update -------------- diff --git a/doc/source/configuration/reference/vgpu.rst b/doc/source/configuration/reference/vgpu.rst index 693c89d21..fcfe6cef9 100644 --- a/doc/source/configuration/reference/vgpu.rst +++ b/doc/source/configuration/reference/vgpu.rst @@ -226,7 +226,7 @@ To apply the configuration to Nova: .. code:: shell - (kayobe) $ kayobe overcloud service deploy -kt nova + (kayobe) $ kayobe overcloud service deploy -t nova OpenStack flavors ================= @@ -307,4 +307,4 @@ Reconfigure nova to match the change: .. code:: shell - (kayobe) $ kayobe overcloud service reconfigure -kt nova --kolla-limit computegpu000 --skip-prechecks + (kayobe) $ kayobe overcloud service reconfigure -t nova --limit computegpu000 --skip-prechecks diff --git a/doc/source/deployment.rst b/doc/source/deployment.rst index c39d0fe3c..5148700a8 100644 --- a/doc/source/deployment.rst +++ b/doc/source/deployment.rst @@ -217,9 +217,10 @@ After this command has completed the seed services will be active. .. note:: - Bifrost deployment behaviour is split between Kayobe and Kolla-Ansible. As - such, you should use both ``--tags kolla-bifrost`` and ``--kolla-tags - bifrost`` if you want to limit to Bifrost deployment. + You can use ``--tags bifrost`` if you want to limit to just the Bifrost + deployment. Note however that using tags is not tested in either Kayobe or + Kolla-Ansible CI, and as such should only be used if you know what you're + doing. Proceed with caution. .. seealso:: diff --git a/doc/source/upgrading.rst b/doc/source/upgrading.rst index bbe6b1d96..b0e221aca 100644 --- a/doc/source/upgrading.rst +++ b/doc/source/upgrading.rst @@ -450,7 +450,13 @@ To upgrade the containerised control plane services:: (kayobe) $ kayobe overcloud service upgrade -It is possible to specify tags for Kayobe and/or kolla-ansible to restrict the +It is possible to specify tags for Kayobe and kolla-ansible to restrict the scope of the upgrade:: - (kayobe) $ kayobe overcloud service upgrade --tags config --kolla-tags keystone + (kayobe) $ kayobe overcloud service upgrade --tags keystone + +.. note:: + + Using tags is not tested in either Kayobe or Kolla-Ansible CI, and as such + should only be used if you know what you're doing. Proceed with caution. + diff --git a/doc/source/usage.rst b/doc/source/usage.rst index 499a1b5ee..94269f959 100644 --- a/doc/source/usage.rst +++ b/doc/source/usage.rst @@ -63,12 +63,11 @@ Limiting Hosts Sometimes it may be necessary to limit execution of kayobe or kolla-ansible plays to a subset of the hosts. The ``--limit `` argument allows the -kayobe ansible hosts to be limited. The ``--kolla-limit `` argument -allows the kolla-ansible hosts to be limited. These two options may be -combined in a single command. In both cases, the argument provided should be +kayobe and kolla-ansible hosts to be limited. The argument provided should be an `Ansible host pattern `_, and will -ultimately be passed to ``ansible-playbook`` as a ``--limit`` argument. +ultimately be passed to ``ansible-playbook`` for both kayobe and kolla-ansible +as a ``--limit`` argument. .. _usage-tags: @@ -77,12 +76,15 @@ Tags `Ansible tags `_ provide a useful mechanism for executing a subset of the plays or tasks in a -playbook. The ``--tags `` argument allows execution of kayobe ansible -playbooks to be limited to matching plays and tasks. The ``--kolla-tags -`` argument allows execution of kolla-ansible ansible playbooks to be -limited to matching plays and tasks. The ``--skip-tags `` and -``--kolla-skip-tags `` arguments allow for avoiding execution of matching -plays and tasks. +playbook. The ``--tags `` argument allows execution of kayobe and +kolla-ansible playbooks to be limited to matching plays and tasks. The +``--skip-tags `` argument allows for avoiding execution of matching plays +and tasks. + +.. note:: + + Using tags is not tested in either Kayobe or Kolla-Ansible CI, and as such + should only be used if you know what you're doing. Proceed with caution. Check and diff mode ------------------- diff --git a/kayobe/ansible.py b/kayobe/ansible.py index caa6df7b1..6abd416a3 100644 --- a/kayobe/ansible.py +++ b/kayobe/ansible.py @@ -213,6 +213,8 @@ def build_args(parsed_args, playbooks, cmd += ["--skip-tags", parsed_args.skip_tags] if parsed_args.tags or tags: all_tags = [t for t in [parsed_args.tags, tags] if t] + # Always run kayobe-generate-config (unless the tag is skipped). + all_tags += ["kayobe-generate-config"] cmd += ["--tags", ",".join(all_tags)] cmd += playbooks return cmd diff --git a/kayobe/cli/commands.py b/kayobe/cli/commands.py index 4dbcb4cdc..a0213be70 100644 --- a/kayobe/cli/commands.py +++ b/kayobe/cli/commands.py @@ -14,6 +14,7 @@ import glob import json +import logging import os import re import sys @@ -30,6 +31,8 @@ # This is set to an arbitrary large number to simplify the sorting logic DEFAULT_SEQUENCE_NUMBER = sys.maxsize +LOG = logging.getLogger(__name__) + def _build_playbook_list(*playbooks): """Return a list of names of playbook files given their basenames.""" @@ -115,6 +118,31 @@ def generate_kolla_ansible_config(self, parsed_args, install=False, self.run_kayobe_playbooks(parsed_args, playbooks, ignore_limit=True, check=False) + def handle_kolla_tags_limits_deprecation(self, parsed_args): + if (parsed_args.kolla_limit or parsed_args.kolla_tags or + parsed_args.kolla_skip_tags): + self.app.LOG.warning("The use of --kolla-tags, --kolla-limit, and " + "--kolla-skip-tags is deprecated. Please " + "switch to just using --tags, --limit, or " + "--skip-tags, these are now passed into " + "kolla-ansible too. Kolla tags/limit will be " + "removed in the next release.") + if parsed_args.limit and parsed_args.kolla_limit: + self.app.LOG.error("You can no longer use both --limit and " + "--kolla-limit at the same time. Please switch " + "to just using --limit") + sys.exit(1) + if parsed_args.tags and parsed_args.kolla_tags: + self.app.LOG.error("You can no longer use both --tags and " + "--kolla-tags at the same time. Please switch " + "to just using --tags") + sys.exit(1) + if parsed_args.skip_tags and parsed_args.kolla_skip_tags: + self.app.LOG.error("You can no longer use both --skip-tags and " + "--kolla-skip-tags at the same time. Please " + "switch to just using --skip-tags") + sys.exit(1) + class KollaAnsibleMixin(object): """Mixin class for commands running Kolla Ansible.""" @@ -277,6 +305,7 @@ def get_parser(self, prog_name): 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) playbooks = _build_playbook_list("bootstrap") @@ -503,6 +532,8 @@ def add_kolla_ansible_args(self, group): def take_action(self, parsed_args): self.app.LOG.debug("Running Kolla Ansible command") + self.handle_kolla_tags_limits_deprecation(parsed_args) + # First prepare configuration. self.generate_kolla_ansible_config(parsed_args) @@ -683,6 +714,7 @@ class SeedVMProvision(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin, def take_action(self, parsed_args): self.app.LOG.debug("Provisioning seed VM") + self.handle_kolla_tags_limits_deprecation(parsed_args) self.run_kayobe_playbook(parsed_args, _get_playbook_path("ip-allocation"), limit="seed") @@ -701,6 +733,7 @@ class SeedVMDeprovision(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin, def take_action(self, parsed_args): self.app.LOG.debug("Deprovisioning seed VM") + self.handle_kolla_tags_limits_deprecation(parsed_args) self.run_kayobe_playbook(parsed_args, _get_playbook_path("seed-vm-deprovision")) @@ -836,6 +869,7 @@ class SeedServiceDeploy(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin, def take_action(self, parsed_args): self.app.LOG.debug("Deploying seed services") + self.handle_kolla_tags_limits_deprecation(parsed_args) playbooks = _build_playbook_list( "seed-manage-containers") extra_vars = {"kayobe_action": "deploy"} @@ -870,6 +904,7 @@ def take_action(self, parsed_args): "you understand this.") sys.exit(1) self.app.LOG.debug("Destroying seed services") + self.handle_kolla_tags_limits_deprecation(parsed_args) self.generate_kolla_ansible_config(parsed_args, service_config=False, bifrost_config=False) extra_args = ["--yes-i-really-really-mean-it"] @@ -911,6 +946,7 @@ class SeedServiceUpgrade(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin, def take_action(self, parsed_args): self.app.LOG.debug("Upgrading seed services") + self.handle_kolla_tags_limits_deprecation(parsed_args) playbooks = _build_playbook_list( "seed-manage-containers") extra_vars = {"kayobe_action": "deploy"} @@ -1278,6 +1314,8 @@ class OvercloudFactsGather(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin, def take_action(self, parsed_args): self.app.LOG.debug("Gathering overcloud host facts") + self.handle_kolla_tags_limits_deprecation(parsed_args) + # Gather facts for Kayobe. playbooks = _build_playbook_list("overcloud-facts-gather") self.run_kayobe_playbooks(parsed_args, playbooks) @@ -1415,6 +1453,8 @@ def get_parser(self, prog_name): def take_action(self, parsed_args): self.app.LOG.debug("Performing overcloud database backup") + self.handle_kolla_tags_limits_deprecation(parsed_args) + # First prepare configuration. self.generate_kolla_ansible_config(parsed_args, service_config=False) @@ -1443,6 +1483,8 @@ def take_action(self, parsed_args): self.app.LOG.debug("Performing overcloud database recovery") extra_vars = {} + self.handle_kolla_tags_limits_deprecation(parsed_args) + # First prepare configuration. self.generate_kolla_ansible_config(parsed_args, service_config=True) @@ -1480,6 +1522,8 @@ def get_parser(self, prog_name): def take_action(self, parsed_args): self.app.LOG.debug("Generating overcloud service configuration") + self.handle_kolla_tags_limits_deprecation(parsed_args) + # First prepare configuration. self.generate_kolla_ansible_config(parsed_args) @@ -1511,6 +1555,7 @@ def get_parser(self, prog_name): def take_action(self, parsed_args): self.app.LOG.debug("Validating overcloud service configuration") + self.handle_kolla_tags_limits_deprecation(parsed_args) extra_vars = {} if parsed_args.output_dir: extra_vars[ @@ -1575,8 +1620,8 @@ class OvercloudServiceDeploy(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin, * Configure and deploy kayobe extra services. * Generate openrc files for the admin user. - This can be used in conjunction with the --tags and --kolla-tags arguments - to deploy specific services. + This can be used in conjunction with the --tags argument to deploy specific + services. """ def get_parser(self, prog_name): @@ -1589,6 +1634,8 @@ def get_parser(self, prog_name): def take_action(self, parsed_args): self.app.LOG.debug("Deploying overcloud services") + self.handle_kolla_tags_limits_deprecation(parsed_args) + # First prepare configuration. self.generate_kolla_ansible_config(parsed_args) @@ -1624,8 +1671,8 @@ class OvercloudServiceDeployContainers(KollaAnsibleMixin, KayobeAnsibleMixin, * Perform a kolla-ansible deployment of the overcloud service containers. * Configure and deploy kayobe extra services. - This can be used in conjunction with the --tags and --kolla-tags arguments - to deploy specific services. + This can be used in conjunction with the --tags argument to deploy specific + services. """ def get_parser(self, prog_name): @@ -1639,6 +1686,8 @@ def get_parser(self, prog_name): def take_action(self, parsed_args): self.app.LOG.debug("Deploying overcloud services (containers only)") + self.handle_kolla_tags_limits_deprecation(parsed_args) + # First prepare configuration. self.generate_kolla_ansible_config(parsed_args) @@ -1665,13 +1714,15 @@ class OvercloudServicePrechecks(KollaAnsibleMixin, KayobeAnsibleMixin, * Perform kolla-ansible prechecks to verify the system state for deployment. - This can be used in conjunction with the --tags and --kolla-tags arguments - to check specific services. + This can be used in conjunction with the --tags argument to check specific + services. """ def take_action(self, parsed_args): self.app.LOG.debug("Running overcloud prechecks") + self.handle_kolla_tags_limits_deprecation(parsed_args) + # First prepare configuration. self.generate_kolla_ansible_config(parsed_args) @@ -1699,8 +1750,8 @@ class OvercloudServiceReconfigure(KollaAnsibleMixin, KayobeAnsibleMixin, * Configure and deploy kayobe extra services. * Generate openrc files for the admin user. - This can be used in conjunction with the --tags and --kolla-tags arguments - to reconfigure specific services. + This can be used in conjunction with the --tags argument to reconfigure + specific services. """ def get_parser(self, prog_name): @@ -1713,6 +1764,8 @@ def get_parser(self, prog_name): def take_action(self, parsed_args): self.app.LOG.debug("Reconfiguring overcloud services") + self.handle_kolla_tags_limits_deprecation(parsed_args) + # First prepare configuration. self.generate_kolla_ansible_config(parsed_args) @@ -1746,8 +1799,8 @@ class OvercloudServiceStop(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin, * Perform a kolla-ansible stop of the overcloud services. * Stop kayobe extra services. - This can be used in conjunction with the --tags and --kolla-tags arguments - to stop specific services. + This can be used in conjunction with the --tags argument to stop specific + services. """ def get_parser(self, prog_name): @@ -1768,6 +1821,8 @@ def take_action(self, parsed_args): self.app.LOG.debug("Stopping overcloud services") + self.handle_kolla_tags_limits_deprecation(parsed_args) + # First prepare configuration. self.generate_kolla_ansible_config(parsed_args) @@ -1795,8 +1850,8 @@ class OvercloudServiceUpgrade(KollaAnsibleMixin, KayobeAnsibleMixin, * Configure and upgrade kayobe extra services. * Regenerate openrc files for the admin user. - This can be used in conjunction with the --tags and --kolla-tags arguments - to upgrade specific services. + This can be used in conjunction with the --tags argument to upgrade + specific services. """ def get_parser(self, prog_name): @@ -1809,6 +1864,8 @@ def get_parser(self, prog_name): def take_action(self, parsed_args): self.app.LOG.debug("Upgrading overcloud services") + self.handle_kolla_tags_limits_deprecation(parsed_args) + # First prepare configuration. self.generate_kolla_ansible_config(parsed_args, install=True) @@ -1860,6 +1917,8 @@ def take_action(self, parsed_args): self.app.LOG.debug("Destroying overcloud services") + self.handle_kolla_tags_limits_deprecation(parsed_args) + # First prepare configuration. self.generate_kolla_ansible_config(parsed_args) @@ -1882,6 +1941,8 @@ class OvercloudContainerImagePull(KayobeAnsibleMixin, KollaAnsibleMixin, def take_action(self, parsed_args): self.app.LOG.debug("Pulling overcloud container images") + self.handle_kolla_tags_limits_deprecation(parsed_args) + # First prepare configuration. self.generate_kolla_ansible_config(parsed_args, service_config=False) diff --git a/kayobe/cmd/kayobe.py b/kayobe/cmd/kayobe.py index 5e3fadf15..25f5f44ec 100644 --- a/kayobe/cmd/kayobe.py +++ b/kayobe/cmd/kayobe.py @@ -18,6 +18,31 @@ from kayobe import version +import logging + + +class CustomFormatter(logging.Formatter): + + grey = "\x1b[38;20m" + yellow = "\x1b[33;20m" + red = "\x1b[31;20m" + bold_red = "\x1b[31;1m" + reset = "\x1b[0m" + format = "[%(levelname)s]: %(message)s" + + FORMATS = { + logging.DEBUG: grey + format + reset, + logging.INFO: grey + format + reset, + logging.WARNING: yellow + format + reset, + logging.ERROR: red + format + reset, + logging.CRITICAL: bold_red + format + reset + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) + class KayobeApp(App): @@ -33,6 +58,14 @@ def __init__(self): def initialize_app(self, argv): self.LOG.debug('initialize_app') + def configure_logging(self): + super().configure_logging() + root_logger = logging.getLogger('') + # Override log formatter + for handler in root_logger.handlers: + if isinstance(handler, logging.StreamHandler): + handler.setFormatter(CustomFormatter()) + def prepare_to_run_command(self, cmd): self.LOG.debug('prepare_to_run_command %s', cmd.__class__.__name__) diff --git a/kayobe/kolla_ansible.py b/kayobe/kolla_ansible.py index 7b5c990c9..7304fe82d 100644 --- a/kayobe/kolla_ansible.py +++ b/kayobe/kolla_ansible.py @@ -54,16 +54,20 @@ def add_args(parser): "Kolla Ansible" % (CONFIG_PATH_ENV, DEFAULT_CONFIG_PATH), action='append') + # TODO(mattcrees): Remove kl, kt, and kolla-skip-tags in 2026.2. parser.add_argument("-kl", "--kolla-limit", metavar="SUBSET", - help="further limit selected hosts to an additional " + help="[DEPRECATED: Please use -l or --limit instead] " + "further limit selected hosts to an additional " "pattern") parser.add_argument("-kp", "--kolla-playbook", metavar="PLAYBOOK", help="path to Ansible playbook file") parser.add_argument("--kolla-skip-tags", metavar="TAGS", - help="only run plays and tasks whose tags do not " - "match these values in Kolla Ansible") + help="[DEPRECATED: Please use -skip-tags instead] " + "only run plays and tasks whose tags " + "do not match these values in Kolla Ansible") parser.add_argument("-kt", "--kolla-tags", metavar="TAGS", - help="only run plays and tasks tagged with these " + help="[DEPRECATED: Please use -t or --tags instead] " + "only run plays and tasks tagged with these " "values in Kolla Ansible") parser.add_argument("--kolla-venv", metavar="VENV", default=default_venv, help="path to virtualenv where Kolla Ansible is " @@ -162,13 +166,17 @@ def build_args(parsed_args, command, inventory_filename, extra_vars=None, # Quote and escape variables originating within the python CLI. extra_var_value = utils.quote_and_escape(extra_var_value) cmd += ["-e", "%s=%s" % (extra_var_name, extra_var_value)] - if parsed_args.kolla_limit or limit: - limit_arg = utils.intersect_limits(parsed_args.kolla_limit, limit) + if parsed_args.limit or parsed_args.kolla_limit or limit: + limit_arg = utils.intersect_limits(parsed_args.limit, limit) + limit_arg = utils.intersect_limits(parsed_args.kolla_limit, limit_arg) cmd += ["--limit", utils.quote_and_escape(limit_arg)] - if parsed_args.kolla_skip_tags: - cmd += ["--skip-tags", parsed_args.kolla_skip_tags] - if parsed_args.kolla_tags or tags: - all_tags = [t for t in [parsed_args.kolla_tags, tags] if t] + if parsed_args.skip_tags or parsed_args.kolla_skip_tags: + all_tags = [t for t in [parsed_args.skip_tags, + parsed_args.kolla_skip_tags] if t] + cmd += ["--skip-tags", ",".join(all_tags)] + if parsed_args.tags or parsed_args.kolla_tags or tags: + all_tags = [t for t in [parsed_args.tags, parsed_args.kolla_tags, + tags] if t] cmd += ["--tags", ",".join(all_tags)] if parsed_args.list_tasks: cmd += ["--list-tasks"] diff --git a/kayobe/tests/unit/test_ansible.py b/kayobe/tests/unit/test_ansible.py index 00600287d..10352dd25 100644 --- a/kayobe/tests/unit/test_ansible.py +++ b/kayobe/tests/unit/test_ansible.py @@ -222,7 +222,7 @@ def test_run_playbooks_all_the_args(self, mock_validate, mock_vars, "--check", "--diff", "--limit", "group1:host", - "--tags", "tag1,tag2", + "--tags", "tag1,tag2,kayobe-generate-config", "playbook1.yml", "playbook2.yml", ] @@ -300,7 +300,7 @@ def test_run_playbooks_all_the_long_args(self, mock_ask, mock_validate, "--diff", "--limit", "group1:host1", "--skip-tags", "tag3,tag4", - "--tags", "tag1,tag2", + "--tags", "tag1,tag2,kayobe-generate-config", "playbook1.yml", "playbook2.yml", ] @@ -450,7 +450,7 @@ def test_run_playbooks_func_args(self, mock_validate, mock_vars, mock_run): "--check", "--diff", "--limit", "group1:host1:&group2:host2", - "--tags", "tag1,tag2,tag3,tag4", + "--tags", "tag1,tag2,tag3,tag4,kayobe-generate-config", "playbook1.yml", "playbook2.yml", ] diff --git a/kayobe/tests/unit/test_kolla_ansible.py b/kayobe/tests/unit/test_kolla_ansible.py index 68ddab221..d74e753b8 100644 --- a/kayobe/tests/unit/test_kolla_ansible.py +++ b/kayobe/tests/unit/test_kolla_ansible.py @@ -61,8 +61,8 @@ def test_run_all_the_args(self, mock_validate, mock_run): "--kolla-config-path", "/path/to/config", "-ke", "ev_name1=ev_value1", "-ki", "/path/to/inventory", - "-kl", "host1:host2", - "-kt", "tag1,tag2", + "-l", "host1:host2", + "-t", "tag1,tag2", "-kp", "/path/to/playbook", ] parsed_args = parser.parse_args(args) @@ -100,9 +100,9 @@ def test_run_all_the_long_args(self, mock_ask, mock_validate, mock_run): "--kolla-config-path", "/path/to/config", "--kolla-extra-vars", "ev_name1=ev_value1", "--kolla-inventory", "/path/to/inventory", - "--kolla-limit", "host1:host2", - "--kolla-skip-tags", "tag3,tag4", - "--kolla-tags", "tag1,tag2", + "--limit", "host1:host2", + "--skip-tags", "tag3,tag4", + "--tags", "tag1,tag2", "--kolla-playbook", "/path/to/playbook", ] parsed_args = parser.parse_args(args) @@ -194,7 +194,7 @@ def test_run_func_args(self, mock_validate, mock_run): vault.add_args(parser) args = [ "--kolla-extra-vars", "ev_name1=ev_value1", - "--kolla-tags", "tag1,tag2", + "--tags", "tag1,tag2", ] parsed_args = parser.parse_args(args) kwargs = { diff --git a/releasenotes/notes/drop-kolla-tags-and-kolla-limits-254faef5584176e1.yaml b/releasenotes/notes/drop-kolla-tags-and-kolla-limits-254faef5584176e1.yaml new file mode 100644 index 000000000..854451fbd --- /dev/null +++ b/releasenotes/notes/drop-kolla-tags-and-kolla-limits-254faef5584176e1.yaml @@ -0,0 +1,22 @@ +--- +features: + - | + Added the tag ``bifrost`` to ``kolla-bifrost.yml`` so that we can easily + limit to Bifrost in ``kayobe seed service deploy``. + - | + Removed the options ``--kolla-tags`` and ``kolla-limit`` from all commands. + Regular ``--tags`` and ``--limit`` will now be passed directly to the + Kolla-Ansible invocations. Added the tag ``kayobe-generate-config`` to + ``kolla-ansible.yml`` and ``kolla-openstack.yml``. This tag is now always + called, to allow for limiting to OpenStack services with just one tag, e.g. + ``kayobe overcloud service deploy -t nova`. You can still skip this with + ``--skip-tags kayobe-generate-config``. +upgrade: + - | + Removed the options ``--kolla-tags`` and ``kolla-limit`` from all commands. + Regular ``--tags`` and ``--limit`` will now be passed directly to the + Kolla-Ansible invocations. Added the tag ``kayobe-generate-config`` to + ``kolla-ansible.yml`` and ``kolla-openstack.yml``. This tag is now always + called, to allow for limiting to OpenStack services with just one tag, e.g. + ``kayobe overcloud service deploy -t nova`. You can still skip this with + ``--skip-tags kayobe-generate-config``. From bf8bd3403be7f694eadf9773aedfb02bbfbf8d0b Mon Sep 17 00:00:00 2001 From: Pierre Riteau Date: Tue, 17 Mar 2026 18:24:12 +0100 Subject: [PATCH 09/11] CI: Bump ansible-lint to 26.x Change-Id: I5f20b51346eb58d4a5cbf921b5387beb1056b24e Signed-off-by: Pierre Riteau --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index f5270c8d3..0f57abc8e 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -2,7 +2,7 @@ # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. -ansible-lint>=25.0.0,<26.0.0 # MIT +ansible-lint>=26.0.0,<27.0.0 # MIT bandit>=1.1.0 # Apache-2.0 bashate>=0.2 # Apache-2.0 coverage>=4.0 # Apache-2.0 From 69e977e25d1d451489c20a1d071bc9170ba96356 Mon Sep 17 00:00:00 2001 From: Leonie Chamberlin-Medd Date: Mon, 12 Jan 2026 13:03:24 +0000 Subject: [PATCH 10/11] Add support for fail2ban in Kayobe Adds support for installing and configuring fail2ban in Kayobe using the robertdebock.fail2ban Ansible role https://galaxy.ansible.com/ui/standalone/roles/robertdebock/fail2ban/ Change-Id: Ic484b2c4f6e261a5173ba8f5378258068f468fa2 Signed-off-by: Leonie Chamberlin-Medd --- ansible/control-host-configure.yml | 1 + ansible/fail2ban.yml | 17 ++++++++ ansible/infra-vm-host-configure.yml | 1 + .../inventory/group_vars/all/ansible-control | 20 ++++++++++ ansible/inventory/group_vars/all/compute | 20 ++++++++++ ansible/inventory/group_vars/all/controllers | 20 ++++++++++ ansible/inventory/group_vars/all/infra-vms | 20 ++++++++++ ansible/inventory/group_vars/all/monitoring | 20 ++++++++++ ansible/inventory/group_vars/all/seed | 20 ++++++++++ .../inventory/group_vars/all/seed-hypervisor | 20 ++++++++++ ansible/inventory/group_vars/all/storage | 20 ++++++++++ .../group_vars/ansible-control/fail2ban | 6 +++ ansible/inventory/group_vars/compute/fail2ban | 6 +++ .../inventory/group_vars/controllers/fail2ban | 6 +++ .../inventory/group_vars/infra-vms/fail2ban | 6 +++ .../inventory/group_vars/monitoring/fail2ban | 6 +++ .../group_vars/seed-hypervisor/fail2ban | 6 +++ ansible/inventory/group_vars/seed/fail2ban | 6 +++ ansible/inventory/group_vars/storage/fail2ban | 6 +++ ansible/overcloud-host-configure.yml | 1 + ansible/seed-host-configure.yml | 1 + ansible/seed-hypervisor-host-configure.yml | 1 + doc/source/configuration/reference/hosts.rst | 39 +++++++++++++++++++ etc/kayobe/ansible-control.yml | 15 +++++++ etc/kayobe/compute.yml | 15 +++++++ etc/kayobe/controllers.yml | 15 +++++++ etc/kayobe/infra-vms.yml | 15 +++++++ etc/kayobe/monitoring.yml | 15 +++++++ etc/kayobe/seed-hypervisor.yml | 15 +++++++ etc/kayobe/seed.yml | 15 +++++++ etc/kayobe/storage.yml | 15 +++++++ .../overrides.yml.j2 | 6 +++ .../tests/test_overcloud_host_configure.py | 10 +++++ .../support-fail2ban-b25a26d66cfbcaaf.yaml | 6 +++ requirements.yml | 2 + zuul.d/jobs.yaml | 4 ++ 36 files changed, 417 insertions(+) create mode 100644 ansible/fail2ban.yml create mode 100644 ansible/inventory/group_vars/ansible-control/fail2ban create mode 100644 ansible/inventory/group_vars/compute/fail2ban create mode 100644 ansible/inventory/group_vars/controllers/fail2ban create mode 100644 ansible/inventory/group_vars/infra-vms/fail2ban create mode 100644 ansible/inventory/group_vars/monitoring/fail2ban create mode 100644 ansible/inventory/group_vars/seed-hypervisor/fail2ban create mode 100644 ansible/inventory/group_vars/seed/fail2ban create mode 100644 ansible/inventory/group_vars/storage/fail2ban create mode 100644 releasenotes/notes/support-fail2ban-b25a26d66cfbcaaf.yaml diff --git a/ansible/control-host-configure.yml b/ansible/control-host-configure.yml index 996da2c68..fb58add50 100644 --- a/ansible/control-host-configure.yml +++ b/ansible/control-host-configure.yml @@ -13,6 +13,7 @@ - import_playbook: "selinux.yml" - import_playbook: "network.yml" - import_playbook: "firewall.yml" +- import_playbook: "fail2ban.yml" - import_playbook: "tuned.yml" - import_playbook: "sysctl.yml" - import_playbook: "time.yml" diff --git a/ansible/fail2ban.yml b/ansible/fail2ban.yml new file mode 100644 index 000000000..5f85f0a0e --- /dev/null +++ b/ansible/fail2ban.yml @@ -0,0 +1,17 @@ +--- +- name: Configure fail2ban + hosts: seed:seed-hypervisor:overcloud:infra-vms:ansible-control + max_fail_percentage: >- + {{ fail2ban_max_fail_percentage | + default(host_configure_max_fail_percentage) | + default(kayobe_max_fail_percentage) | + default(100) }} + tags: + - fail2ban + roles: + - role: robertdebock.fail2ban + become: true + when: fail2ban_enabled | bool + vars: + # TODO (L-Chams): Remove fail2ban_sender override when PR https://github.com/robertdebock/ansible-role-fail2ban/pull/18 is merged. + fail2ban_sender: root@{{ ansible_facts.fqdn }} diff --git a/ansible/infra-vm-host-configure.yml b/ansible/infra-vm-host-configure.yml index 644c7f03a..2d1595074 100644 --- a/ansible/infra-vm-host-configure.yml +++ b/ansible/infra-vm-host-configure.yml @@ -13,6 +13,7 @@ - import_playbook: "selinux.yml" - import_playbook: "network.yml" - import_playbook: "firewall.yml" +- import_playbook: "fail2ban.yml" - import_playbook: "tuned.yml" - import_playbook: "sysctl.yml" - import_playbook: "disable-glean.yml" diff --git a/ansible/inventory/group_vars/all/ansible-control b/ansible/inventory/group_vars/all/ansible-control index 0f9f555c0..635024df0 100644 --- a/ansible/inventory/group_vars/all/ansible-control +++ b/ansible/inventory/group_vars/all/ansible-control @@ -126,6 +126,26 @@ ansible_control_firewalld_default_zone: # - state: enabled ansible_control_firewalld_rules: [] +############################################################################### +# Ansible control host fail2ban configuration. + +# Whether to install and enable fail2ban. Default is false. +ansible_control_fail2ban_enabled: false + +# List of fail2ban jails for the Ansible control host. +ansible_control_fail2ban_jail_configuration: >- + {{ ansible_control_fail2ban_jail_configuration_default + + ansible_control_fail2ban_jail_configuration_extra }} + +# List of default fail2ban jails for the Ansible control host. +ansible_control_fail2ban_jail_configuration_default: + - option: enabled + value: "true" + section: sshd + +# List of extra fail2ban jails for the Ansible control host. +ansible_control_fail2ban_jail_configuration_extra: [] + ############################################################################### # Ansible control host swap configuration. diff --git a/ansible/inventory/group_vars/all/compute b/ansible/inventory/group_vars/all/compute index 1803098cc..99fb9dc3c 100644 --- a/ansible/inventory/group_vars/all/compute +++ b/ansible/inventory/group_vars/all/compute @@ -185,6 +185,26 @@ compute_firewalld_default_zone: # - state: enabled compute_firewalld_rules: [] +############################################################################### +# Compute node fail2ban configuration. + +# Whether to install and enable fail2ban. +compute_fail2ban_enabled: false + +# List of fail2ban jails for the compute node. +compute_fail2ban_jail_configuration: >- + {{ compute_fail2ban_jail_configuration_default + + compute_fail2ban_jail_configuration_extra }} + +# List of default fail2ban jails for the compute node. +compute_fail2ban_jail_configuration_default: + - option: enabled + value: "true" + section: sshd + +# List of extra fail2ban jails for the compute node. +compute_fail2ban_jail_configuration_extra: [] + ############################################################################### # Compute node host libvirt configuration. diff --git a/ansible/inventory/group_vars/all/controllers b/ansible/inventory/group_vars/all/controllers index a7b2097bd..f8be1616a 100644 --- a/ansible/inventory/group_vars/all/controllers +++ b/ansible/inventory/group_vars/all/controllers @@ -224,6 +224,26 @@ controller_firewalld_default_zone: # - state: enabled controller_firewalld_rules: [] +############################################################################### +# Controller node fail2ban configuration. + +# Whether to install and enable fail2ban. +controller_fail2ban_enabled: false + +# List of fail2ban jails for the controller node. +controller_fail2ban_jail_configuration: >- + {{ controller_fail2ban_jail_configuration_default + + controller_fail2ban_jail_configuration_extra }} + +# List of default fail2ban jails for the controller node. +controller_fail2ban_jail_configuration_default: + - option: enabled + value: "true" + section: sshd + +# List of extra fail2ban jails for the controller node. +controller_fail2ban_jail_configuration_extra: [] + ############################################################################### # Controller node swap configuration. diff --git a/ansible/inventory/group_vars/all/infra-vms b/ansible/inventory/group_vars/all/infra-vms index 58b91b97a..df0b8bcaa 100644 --- a/ansible/inventory/group_vars/all/infra-vms +++ b/ansible/inventory/group_vars/all/infra-vms @@ -230,6 +230,26 @@ infra_vm_firewalld_default_zone: # - state: enabled infra_vm_firewalld_rules: [] +############################################################################### +# Infrastructure VM node fail2ban configuration. + +# Whether to install and enable fail2ban. +infra_vm_fail2ban_enabled: false + +# List of fail2ban jails for the infrastructure VM node. +infra_vm_fail2ban_jail_configuration: >- + {{ infra_vm_fail2ban_jail_configuration_default + + infra_vm_fail2ban_jail_configuration_extra }} + +# List of default fail2ban jails for the infrastructure VM node. +infra_vm_fail2ban_jail_configuration_default: + - option: enabled + value: "true" + section: sshd + +# List of extra fail2ban jails for the infrastructure VM node. +infra_vm_fail2ban_jail_configuration_extra: [] + ############################################################################### # Infrastructure VM node swap configuration. diff --git a/ansible/inventory/group_vars/all/monitoring b/ansible/inventory/group_vars/all/monitoring index ee1fa4ebc..61492f2b2 100644 --- a/ansible/inventory/group_vars/all/monitoring +++ b/ansible/inventory/group_vars/all/monitoring @@ -124,6 +124,26 @@ monitoring_firewalld_default_zone: "{{ controller_firewalld_default_zone }}" # - state: enabled monitoring_firewalld_rules: "{{ controller_firewalld_rules }}" +############################################################################### +# Monitoring node fail2ban configuration. + +# Whether to install and enable fail2ban. +monitoring_fail2ban_enabled: false + +# List of fail2ban jails for the monitoring node. +monitoring_fail2ban_jail_configuration: >- + {{ monitoring_fail2ban_jail_configuration_default + + monitoring_fail2ban_jail_configuration_extra }} + +# List of default fail2ban jails for the monitoring node. +monitoring_fail2ban_jail_configuration_default: + - option: enabled + value: "true" + section: sshd + +# List of extra fail2ban jails for the monitoring node. +monitoring_fail2ban_jail_configuration_extra: [] + ############################################################################### # Monitoring node swap configuration. diff --git a/ansible/inventory/group_vars/all/seed b/ansible/inventory/group_vars/all/seed index 37d4497d9..22c0bc6d4 100644 --- a/ansible/inventory/group_vars/all/seed +++ b/ansible/inventory/group_vars/all/seed @@ -169,6 +169,26 @@ seed_firewalld_default_zone: # - state: enabled seed_firewalld_rules: [] +############################################################################### +# Seed node fail2ban configuration. + +# Whether to install and enable fail2ban. +seed_fail2ban_enabled: false + +# List of fail2ban jails for the seed node. +seed_fail2ban_jail_configuration: >- + {{ seed_fail2ban_jail_configuration_default + + seed_fail2ban_jail_configuration_extra }} + +# List of default fail2ban jails for the seed node. +seed_fail2ban_jail_configuration_default: + - option: enabled + value: "true" + section: sshd + +# List of extra fail2ban jails for the seed node. +seed_fail2ban_jail_configuration_extra: [] + ############################################################################### # Seed node swap configuration. diff --git a/ansible/inventory/group_vars/all/seed-hypervisor b/ansible/inventory/group_vars/all/seed-hypervisor index 80fe27851..47c64b3f7 100644 --- a/ansible/inventory/group_vars/all/seed-hypervisor +++ b/ansible/inventory/group_vars/all/seed-hypervisor @@ -162,6 +162,26 @@ seed_hypervisor_firewalld_default_zone: # - state: enabled seed_hypervisor_firewalld_rules: [] +############################################################################### +# Seed hypervisor node fail2ban configuration. + +# Whether to install and enable fail2ban. +seed_hypervisor_fail2ban_enabled: false + +# List of fail2ban jails for the seed hypervisor node. +seed_hypervisor_fail2ban_jail_configuration: >- + {{ seed_hypervisor_fail2ban_jail_configuration_default + + seed_hypervisor_fail2ban_jail_configuration_extra }} + +# List of default fail2ban jails for the seed hypervisor node. +seed_hypervisor_fail2ban_jail_configuration_default: + - option: enabled + value: "true" + section: sshd + +# List of extra fail2ban jails for the seed hypervisor node. +seed_hypervisor_fail2ban_jail_configuration_extra: [] + ############################################################################### # Seed hypervisor node swap configuration. diff --git a/ansible/inventory/group_vars/all/storage b/ansible/inventory/group_vars/all/storage index 429c0e816..46ea4bc79 100644 --- a/ansible/inventory/group_vars/all/storage +++ b/ansible/inventory/group_vars/all/storage @@ -173,6 +173,26 @@ storage_firewalld_default_zone: # - state: enabled storage_firewalld_rules: [] +############################################################################### +# Storage node fail2ban configuration. + +# Whether to install and enable fail2ban. +storage_fail2ban_enabled: false + +# List of fail2ban jails for the storage node. +storage_fail2ban_jail_configuration: >- + {{ storage_fail2ban_jail_configuration_default + + storage_fail2ban_jail_configuration_extra }} + +# List of default fail2ban jails for the storage node. +storage_fail2ban_jail_configuration_default: + - option: enabled + value: "true" + section: sshd + +# List of extra fail2ban jails for the storage node. +storage_fail2ban_jail_configuration_extra: [] + ############################################################################### # Storage node swap configuration. diff --git a/ansible/inventory/group_vars/ansible-control/fail2ban b/ansible/inventory/group_vars/ansible-control/fail2ban new file mode 100644 index 000000000..d3b3e149b --- /dev/null +++ b/ansible/inventory/group_vars/ansible-control/fail2ban @@ -0,0 +1,6 @@ +--- +# Whether to install and enable fail2ban +fail2ban_enabled: "{{ ansible_control_fail2ban_enabled }}" + +# List of fail2ban jails for the Ansible control host. +fail2ban_jail_configuration: "{{ ansible_control_fail2ban_jail_configuration }}" diff --git a/ansible/inventory/group_vars/compute/fail2ban b/ansible/inventory/group_vars/compute/fail2ban new file mode 100644 index 000000000..bef1836ec --- /dev/null +++ b/ansible/inventory/group_vars/compute/fail2ban @@ -0,0 +1,6 @@ +--- +# Whether to install and enable fail2ban +fail2ban_enabled: "{{ compute_fail2ban_enabled }}" + +# List of fail2ban jails for the compute node. +fail2ban_jail_configuration: "{{ compute_fail2ban_jail_configuration }}" diff --git a/ansible/inventory/group_vars/controllers/fail2ban b/ansible/inventory/group_vars/controllers/fail2ban new file mode 100644 index 000000000..72693b64a --- /dev/null +++ b/ansible/inventory/group_vars/controllers/fail2ban @@ -0,0 +1,6 @@ +--- +# Whether to install and enable fail2ban +fail2ban_enabled: "{{ controller_fail2ban_enabled }}" + +# List of fail2ban jails for the controller node. +fail2ban_jail_configuration: "{{ controller_fail2ban_jail_configuration }}" diff --git a/ansible/inventory/group_vars/infra-vms/fail2ban b/ansible/inventory/group_vars/infra-vms/fail2ban new file mode 100644 index 000000000..f1e6f2a4a --- /dev/null +++ b/ansible/inventory/group_vars/infra-vms/fail2ban @@ -0,0 +1,6 @@ +--- +# Whether to install and enable fail2ban +fail2ban_enabled: "{{ infra_vm_fail2ban_enabled }}" + +# List of fail2ban jails for the infrastructure VM node. +fail2ban_jail_configuration: "{{ infra_vm_fail2ban_jail_configuration }}" diff --git a/ansible/inventory/group_vars/monitoring/fail2ban b/ansible/inventory/group_vars/monitoring/fail2ban new file mode 100644 index 000000000..9160d962f --- /dev/null +++ b/ansible/inventory/group_vars/monitoring/fail2ban @@ -0,0 +1,6 @@ +--- +# Whether to install and enable fail2ban +fail2ban_enabled: "{{ monitoring_fail2ban_enabled }}" + +# List of fail2ban jails for the monitoring node. +fail2ban_jail_configuration: "{{ monitoring_fail2ban_jail_configuration }}" diff --git a/ansible/inventory/group_vars/seed-hypervisor/fail2ban b/ansible/inventory/group_vars/seed-hypervisor/fail2ban new file mode 100644 index 000000000..f1106c883 --- /dev/null +++ b/ansible/inventory/group_vars/seed-hypervisor/fail2ban @@ -0,0 +1,6 @@ +--- +# Whether to install and enable fail2ban +fail2ban_enabled: "{{ seed_hypervisor_fail2ban_enabled }}" + +# List of fail2ban jails for the seed hypervisor node. +fail2ban_jail_configuration: "{{ seed_hypervisor_fail2ban_jail_configuration }}" diff --git a/ansible/inventory/group_vars/seed/fail2ban b/ansible/inventory/group_vars/seed/fail2ban new file mode 100644 index 000000000..dcdf156f9 --- /dev/null +++ b/ansible/inventory/group_vars/seed/fail2ban @@ -0,0 +1,6 @@ +--- +# Whether to install and enable fail2ban +fail2ban_enabled: "{{ seed_fail2ban_enabled }}" + +# List of fail2ban jails for the seed node. +fail2ban_jail_configuration: "{{ seed_fail2ban_jail_configuration }}" diff --git a/ansible/inventory/group_vars/storage/fail2ban b/ansible/inventory/group_vars/storage/fail2ban new file mode 100644 index 000000000..024920388 --- /dev/null +++ b/ansible/inventory/group_vars/storage/fail2ban @@ -0,0 +1,6 @@ +--- +# Whether to install and enable fail2ban +fail2ban_enabled: "{{ storage_fail2ban_enabled }}" + +# List of fail2ban jails for the storage node. +fail2ban_jail_configuration: "{{ storage_fail2ban_jail_configuration }}" diff --git a/ansible/overcloud-host-configure.yml b/ansible/overcloud-host-configure.yml index fff5bc398..a6cdf4d8b 100644 --- a/ansible/overcloud-host-configure.yml +++ b/ansible/overcloud-host-configure.yml @@ -13,6 +13,7 @@ - import_playbook: "selinux.yml" - import_playbook: "network.yml" - import_playbook: "firewall.yml" +- import_playbook: "fail2ban.yml" - import_playbook: "etc-hosts.yml" - import_playbook: "tuned.yml" - import_playbook: "sysctl.yml" diff --git a/ansible/seed-host-configure.yml b/ansible/seed-host-configure.yml index 00c7eed6c..25b0dcc16 100644 --- a/ansible/seed-host-configure.yml +++ b/ansible/seed-host-configure.yml @@ -13,6 +13,7 @@ - import_playbook: "selinux.yml" - import_playbook: "network.yml" - import_playbook: "firewall.yml" +- import_playbook: "fail2ban.yml" - import_playbook: "tuned.yml" - import_playbook: "sysctl.yml" - import_playbook: "ip-routing.yml" diff --git a/ansible/seed-hypervisor-host-configure.yml b/ansible/seed-hypervisor-host-configure.yml index a91f5781a..56f240848 100644 --- a/ansible/seed-hypervisor-host-configure.yml +++ b/ansible/seed-hypervisor-host-configure.yml @@ -13,6 +13,7 @@ - import_playbook: "selinux.yml" - import_playbook: "network.yml" - import_playbook: "firewall.yml" +- import_playbook: "fail2ban.yml" - import_playbook: "tuned.yml" - import_playbook: "sysctl.yml" - import_playbook: "ip-routing.yml" diff --git a/doc/source/configuration/reference/hosts.rst b/doc/source/configuration/reference/hosts.rst index b0ed019a3..da2a1467f 100644 --- a/doc/source/configuration/reference/hosts.rst +++ b/doc/source/configuration/reference/hosts.rst @@ -691,6 +691,45 @@ follows: Note that despite the name, this will not actively enable UFW. It may do so in the future. +Fail2Ban +======== +@tags: + | ``fail2ban`` + +Fail2Ban can be used to ban IP addresses that show malicious signs, such as +ones that conduct too many failed login attempts. Kayobe can install and configure +Fail2Ban on hosts. + +In order to use fail2ban, it is important to note that the user should enable +``dnf_install_epel`` in their configuration when using Rocky Linux or CentOS. + +The following variables can be used to set whether to enable fail2ban: + +* ``ansible_control_fail2ban_enabled`` +* ``seed_hypervisor_fail2ban_enabled`` +* ``seed_fail2ban_enabled`` +* ``infra_vm_fail2ban_enabled`` +* ``compute_fail2ban_enabled`` +* ``controller_fail2ban_enabled`` +* ``monitoring_fail2ban_enabled`` +* ``storage_fail2ban_enabled`` + +The following example demonstrates how to enable fail2ban on controllers. + +.. code-block:: yaml + + controller_fail2ban_enabled: true + +The following should be added in the configuration file to set the default +fail2ban sshd jail: + +.. code-block:: yaml + + fail2ban_jail_configuration: + - option: enabled + value: "true" + section: sshd + .. _configuration-hosts-tuned: Tuned diff --git a/etc/kayobe/ansible-control.yml b/etc/kayobe/ansible-control.yml index cd6b563d5..5232d4470 100644 --- a/etc/kayobe/ansible-control.yml +++ b/etc/kayobe/ansible-control.yml @@ -111,6 +111,21 @@ # - state: enabled #ansible_control_firewalld_rules: +############################################################################### +# Ansible control host fail2ban configuration. + +# Whether to install and enable fail2ban. +#ansible_control_fail2ban_enabled: + +# List of fail2ban jails for the Ansible control host. +#ansible_control_fail2ban_jail_configuration: + +# List of default fail2ban jails for the Ansible control host. +#ansible_control_fail2ban_jail_configuration_default: + +# List of extra fail2ban jails for the Ansible control host. +#ansible_control_fail2ban_jail_configuration_extra: + ############################################################################### # Ansible control host swap configuration. diff --git a/etc/kayobe/compute.yml b/etc/kayobe/compute.yml index f8a7deb29..5240624f1 100644 --- a/etc/kayobe/compute.yml +++ b/etc/kayobe/compute.yml @@ -159,6 +159,21 @@ # - state: enabled #compute_firewalld_rules: +############################################################################### +# Compute node fail2ban configuration. + +# Whether to install and enable fail2ban. +#compute_fail2ban_enabled: + +# List of fail2ban jails for the compute node. +#compute_fail2ban_jail_configuration: + +# List of default fail2ban jails for the compute node. +#compute_fail2ban_jail_configuration_default: + +# List of extra fail2ban jails for the compute node. +#compute_fail2ban_jail_configuration_extra: + ############################################################################### # Compute node host libvirt configuration. diff --git a/etc/kayobe/controllers.yml b/etc/kayobe/controllers.yml index 1cc50c30b..7dd3199f2 100644 --- a/etc/kayobe/controllers.yml +++ b/etc/kayobe/controllers.yml @@ -185,6 +185,21 @@ # - state: enabled #controller_firewalld_rules: +############################################################################### +# Controller node fail2ban configuration. + +# Whether to install and enable fail2ban. +#controller_fail2ban_enabled: + +# List of fail2ban jails for the controller node. +#controller_fail2ban_jail_configuration: + +# List of default fail2ban jails for the controller node. +#controller_fail2ban_jail_configuration_default: + +# List of extra fail2ban jails for the controller node. +#controller_fail2ban_jail_configuration_extra: + ############################################################################### # Controller node swap configuration. diff --git a/etc/kayobe/infra-vms.yml b/etc/kayobe/infra-vms.yml index 59147aac7..50362e59d 100644 --- a/etc/kayobe/infra-vms.yml +++ b/etc/kayobe/infra-vms.yml @@ -188,6 +188,21 @@ # - state: enabled #infra_vm_firewalld_rules: +############################################################################### +# Infrastructure VM node fail2ban configuration. + +# Whether to install and enable fail2ban. +#infra_vm_fail2ban_enabled: + +# List of fail2ban jails for the infrastructure VM node. +#infra_vm_fail2ban_jail_configuration: + +# List of default fail2ban jails for the infrastructure VM node. +#infra_vm_fail2ban_jail_configuration_default: + +# List of extra fail2ban jails for the infrastructure VM node. +#infra_vm_fail2ban_jail_configuration_extra: + ############################################################################### # Infrastructure VM node swap configuration. diff --git a/etc/kayobe/monitoring.yml b/etc/kayobe/monitoring.yml index 8c63bd589..463b3090b 100644 --- a/etc/kayobe/monitoring.yml +++ b/etc/kayobe/monitoring.yml @@ -117,6 +117,21 @@ # - state: enabled #monitoring_firewalld_rules: +############################################################################### +# Monitoring node fail2ban configuration. + +# Whether to install and enable fail2ban. +#monitoring_fail2ban_enabled: + +# List of fail2ban jails for the monitoring node. +#monitoring_fail2ban_jail_configuration: + +# List of default fail2ban jails for the monitoring node. +#monitoring_fail2ban_jail_configuration_default: + +# List of extra fail2ban jails for the monitoring node. +#monitoring_fail2ban_jail_configuration_extra: + ############################################################################### # Monitoring node swap configuration. diff --git a/etc/kayobe/seed-hypervisor.yml b/etc/kayobe/seed-hypervisor.yml index 5905eefe0..8a063703b 100644 --- a/etc/kayobe/seed-hypervisor.yml +++ b/etc/kayobe/seed-hypervisor.yml @@ -136,6 +136,21 @@ # - state: enabled #seed_hypervisor_firewalld_rules: +############################################################################### +# Seed hypervisor node fail2ban configuration. + +# Whether to install and enable fail2ban. +#seed_hypervisor_fail2ban_enabled: + +# List of fail2ban jails for the seed hypervisor node. +#seed_hypervisor_fail2ban_jail_configuration: + +# List of default fail2ban jails for the seed hypervisor node. +#seed_hypervisor_fail2ban_jail_configuration_default: + +# List of extra fail2ban jails for the seed hypervisor node. +#seed_hypervisor_fail2ban_jail_configuration_extra: + ############################################################################### # Seed hypervisor node swap configuration. diff --git a/etc/kayobe/seed.yml b/etc/kayobe/seed.yml index 7a335ea2f..46f1cddc6 100644 --- a/etc/kayobe/seed.yml +++ b/etc/kayobe/seed.yml @@ -143,6 +143,21 @@ # - state: enabled #seed_firewalld_rules: +############################################################################### +# Seed node fail2ban configuration. + +# Whether to install and enable fail2ban. +#seed_fail2ban_enabled: + +# List of fail2ban jails for the seed node. +#seed_fail2ban_jail_configuration: + +# List of default fail2ban jails for the seed node. +#seed_fail2ban_jail_configuration_default: + +# List of extra fail2ban jails for the seed node. +#seed_fail2ban_jail_configuration_extra: + ############################################################################### # Seed node swap configuration. diff --git a/etc/kayobe/storage.yml b/etc/kayobe/storage.yml index 170ed2ab2..53376b4ef 100644 --- a/etc/kayobe/storage.yml +++ b/etc/kayobe/storage.yml @@ -147,6 +147,21 @@ # - state: enabled #storage_firewalld_rules: +############################################################################### +# Storage node fail2ban configuration. + +# Whether to install and enable fail2ban. +#storage_fail2ban_enabled: + +# List of fail2ban jails for the storage node. +#storage_fail2ban_jail_configuration: + +# List of default fail2ban jails for the storage node. +#storage_fail2ban_jail_configuration_default: + +# List of extra fail2ban jails for the storage node. +#storage_fail2ban_jail_configuration_extra: + ############################################################################### # Storage node swap configuration. diff --git a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 index d2e75f779..06933a1d0 100644 --- a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 +++ b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 @@ -212,3 +212,9 @@ controller_swap: # Generate a password for libvirt SASL authentication. compute_libvirt_sasl_password: "{% raw %}{{ lookup('password', '/tmp/libvirt-sasl-password') }}{% endraw %}" + +# Test fail2ban configuration +{% if fail2ban_enabled | bool %} +dnf_use_local_mirror: true +controller_fail2ban_enabled: true +{% endif %} diff --git a/playbooks/kayobe-overcloud-host-configure-base/tests/test_overcloud_host_configure.py b/playbooks/kayobe-overcloud-host-configure-base/tests/test_overcloud_host_configure.py index 442e39f80..f8e394909 100644 --- a/playbooks/kayobe-overcloud-host-configure-base/tests/test_overcloud_host_configure.py +++ b/playbooks/kayobe-overcloud-host-configure-base/tests/test_overcloud_host_configure.py @@ -344,6 +344,16 @@ def test_firewalld_rules(host): assert expected_line in info assert expected_line in perm_info +def test_fail2ban_running(host): + assert host.package("fail2ban").is_installed + assert host.service("fail2ban.service").is_enabled + assert host.service("fail2ban.service").is_running + +def test_fail2ban_default_jail_config(host): + # verify that sshd jail is enabled by default + status = host.check_output("sudo fail2ban-client status sshd") + status = status.splitlines() + assert "Status for the jail: sshd" in status @pytest.mark.skipif(not _is_dnf(), reason="SELinux only supported on CentOS/Rocky") diff --git a/releasenotes/notes/support-fail2ban-b25a26d66cfbcaaf.yaml b/releasenotes/notes/support-fail2ban-b25a26d66cfbcaaf.yaml new file mode 100644 index 000000000..975239e50 --- /dev/null +++ b/releasenotes/notes/support-fail2ban-b25a26d66cfbcaaf.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Adds support for installing and configuring fail2ban. See the docs + http://docs.openstack.org/kayobe/latest/configuration/reference/hosts.html#fail2ban + for more information. diff --git a/requirements.yml b/requirements.yml index e0aa67977..4e2cb7182 100644 --- a/requirements.yml +++ b/requirements.yml @@ -40,6 +40,8 @@ roles: version: v0.2.13 - src: mrlesmithjr.mdadm version: v0.1.9 + - src: robertdebock.fail2ban + version: 5.0.6 - src: singleplatform-eng.users version: v1.2.6 - src: stackhpc.drac diff --git a/zuul.d/jobs.yaml b/zuul.d/jobs.yaml index ed98ae2dc..08a4328ee 100644 --- a/zuul.d/jobs.yaml +++ b/zuul.d/jobs.yaml @@ -313,11 +313,15 @@ name: kayobe-overcloud-host-configure-rocky10 parent: kayobe-overcloud-host-configure-base nodeset: kayobe-rocky10 + vars: + fail2ban_enabled: true - job: name: kayobe-overcloud-host-configure-ubuntu-noble parent: kayobe-overcloud-host-configure-base nodeset: kayobe-ubuntu-noble + vars: + fail2ban_enabled: true - job: name: kayobe-seed-upgrade-base From 8b8ac1c293fb48b5c01fc294bfb0f6e2b313737c Mon Sep 17 00:00:00 2001 From: Grzegorz Koper Date: Mon, 26 Jan 2026 17:04:14 +0100 Subject: [PATCH 11/11] Add nmstate engine Add an opt-in nmstate network engine via `network_engine: nmstate` for host network configuration using NetworkManager/libnmstate. Implement nmstate rendering for Ethernet, VLAN, bond, bridge, routes, routing rules, and structured `*_ethtool_config`. Add OVS patch link (veth pair) generation in nmstate rendering. Change-Id: If3a92b059063b0aea299a51e76217c0d90a637a5 Assisted-by: various models Signed-off-by: Grzegorz Koper --- ansible/filter_plugins/nmstate.py | 22 + ansible/network.yml | 2 +- .../roles/network-nmstate/defaults/main.yml | 2 + .../network-nmstate/library/nmstate_apply.py | 133 +++ ansible/roles/network-nmstate/tasks/main.yml | 204 ++++ ansible/roles/network-nmstate/vars/RedHat.yml | 4 + .../configuration/reference/network.rst | 201 +++- etc/kayobe/globals.yml | 7 + kayobe/plugins/filter/nmstate.py | 626 ++++++++++ .../tests/unit/plugins/filter/test_nmstate.py | 1014 +++++++++++++++++ kayobe/tests/unit/test_nmstate_apply.py | 133 +++ .../kayobe-overcloud-base/overrides.yml.j2 | 6 + .../overrides.yml.j2 | 17 +- ...te-networking-engine-9eca23fe61902134.yaml | 49 + zuul.d/jobs.yaml | 3 + 15 files changed, 2406 insertions(+), 17 deletions(-) create mode 100644 ansible/filter_plugins/nmstate.py create mode 100644 ansible/roles/network-nmstate/defaults/main.yml create mode 100644 ansible/roles/network-nmstate/library/nmstate_apply.py create mode 100644 ansible/roles/network-nmstate/tasks/main.yml create mode 100644 ansible/roles/network-nmstate/vars/RedHat.yml create mode 100644 kayobe/plugins/filter/nmstate.py create mode 100644 kayobe/tests/unit/plugins/filter/test_nmstate.py create mode 100644 kayobe/tests/unit/test_nmstate_apply.py create mode 100644 releasenotes/notes/nmstate-networking-engine-9eca23fe61902134.yaml diff --git a/ansible/filter_plugins/nmstate.py b/ansible/filter_plugins/nmstate.py new file mode 100644 index 000000000..b24e3311b --- /dev/null +++ b/ansible/filter_plugins/nmstate.py @@ -0,0 +1,22 @@ +# Copyright (c) 2026 StackHPC Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from kayobe.plugins.filter import nmstate + + +class FilterModule(object): + """nmstate filters.""" + + def filters(self): + return nmstate.get_filters() diff --git a/ansible/network.yml b/ansible/network.yml index e584b7fd8..8dbc8e0ad 100644 --- a/ansible/network.yml +++ b/ansible/network.yml @@ -43,4 +43,4 @@ - name: Configure the network include_role: - name: "network-{{ ansible_facts.os_family | lower }}" + name: "{{ 'network-nmstate' if network_engine | default('default') == 'nmstate' else 'network-' + ansible_facts.os_family | lower }}" diff --git a/ansible/roles/network-nmstate/defaults/main.yml b/ansible/roles/network-nmstate/defaults/main.yml new file mode 100644 index 000000000..bc5b73765 --- /dev/null +++ b/ansible/roles/network-nmstate/defaults/main.yml @@ -0,0 +1,2 @@ +--- +network_nmstate_install_packages: true diff --git a/ansible/roles/network-nmstate/library/nmstate_apply.py b/ansible/roles/network-nmstate/library/nmstate_apply.py new file mode 100644 index 000000000..f04b89399 --- /dev/null +++ b/ansible/roles/network-nmstate/library/nmstate_apply.py @@ -0,0 +1,133 @@ +#!/usr/bin/python +# Copyright (c) 2026 StackHPC Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import importlib + +from ansible.module_utils.basic import AnsibleModule + +DOCUMENTATION = """ +--- +module: nmstate_apply +version_added: "19.1" +author: "StackHPC" +short_description: Apply network state using nmstate +description: + - "This module allows applying a network state using nmstate library. + Provides idempotency by comparing desired and current states." +options: + state: + description: + - Network state definition in nmstate format + required: True + type: dict + debug: + description: + - Include previous and desired states in output for debugging + required: False + default: False + type: bool +requirements: + - libnmstate +""" + +EXAMPLES = """ +- name: Apply network state + nmstate_apply: + state: + interfaces: + - name: eth0 + type: ethernet + state: up + ipv4: + address: + - ip: 192.168.1.10 + prefix-length: 24 + dhcp: false + debug: false +""" + +RETURN = """ +changed: + description: Whether the network state was modified + type: bool + returned: always +state: + description: Current network state after applying desired state + type: dict + returned: always +previous_state: + description: Network state before applying (when debug=true) + type: dict + returned: when debug=True +desired_state: + description: Desired network state that was applied (when debug=true) + type: dict + returned: when debug=True +""" + + +def run_module(): + argument_spec = dict( + state=dict(required=True, type="dict"), + debug=dict(default=False, type="bool"), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + ) + + try: + libnmstate = importlib.import_module("libnmstate") + except Exception as e: + module.fail_json( + msg=( + "Failed to import libnmstate module. " + "Ensure nmstate Python dependencies are installed " + "(for example python3-libnmstate). " + "Import errors: %s" + ) % repr(e) + ) + + previous_state = libnmstate.show() + desired_state = module.params["state"] + debug = module.params["debug"] + + result = {"changed": False} + + try: + libnmstate.apply(desired_state) + except Exception as e: + module.fail_json(msg="Failed to apply nmstate state: %s" % repr(e)) + + current_state = libnmstate.show() + + if current_state != previous_state: + result["changed"] = True + if debug: + result["previous_state"] = previous_state + result["desired_state"] = desired_state + + result["state"] = current_state + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == "__main__": + main() diff --git a/ansible/roles/network-nmstate/tasks/main.yml b/ansible/roles/network-nmstate/tasks/main.yml new file mode 100644 index 000000000..4bff2ed9d --- /dev/null +++ b/ansible/roles/network-nmstate/tasks/main.yml @@ -0,0 +1,204 @@ +--- +- name: Validate nmstate is supported on this OS + ansible.builtin.assert: + that: + - ansible_facts.os_family == "RedHat" + fail_msg: >- + The nmstate network engine is not supported on {{ ansible_facts.distribution }} + {{ ansible_facts.distribution_version }}. nmstate requires system packages + (nmstate, python3-libnmstate) which are not available in {{ ansible_facts.distribution }} + repositories. The nmstate engine is only supported on Rocky Linux. + For Ubuntu Noble, use the default network engine (network_engine: default) + which provides robust networking via systemd-networkd. + +- name: Include nmstate role variables + include_vars: RedHat.yml + +- import_role: + name: ahuffman.resolv + when: resolv_is_managed | bool + become: true + +- name: Ensure /etc/iproute2 directory exists + ansible.builtin.file: + path: /etc/iproute2 + state: directory + owner: root + group: root + mode: '0755' + become: true + when: + - (network_route_tables | default([])) | length > 0 + +- name: Ensure IP routing tables are defined + blockinfile: + create: true + dest: /etc/iproute2/rt_tables + owner: root + group: root + mode: '0644' + block: | + {% for table in network_route_tables %} + {{ table.id }} {{ table.name }} + {% endfor %} + become: true + when: + - (network_route_tables | default([])) | length > 0 + +- name: Ensure nmstate packages are installed + package: + name: "{{ network_nmstate_packages }}" + state: present + become: true + when: + - network_nmstate_install_packages | bool + - network_nmstate_packages | length > 0 + +- name: Ensure NetworkManager is enabled and running + service: + name: NetworkManager + state: started + enabled: true + become: true + +- name: Ensure NetworkManager DNS config is present only if required + become: true + community.general.ini_file: + path: /etc/NetworkManager/NetworkManager.conf + section: main + option: "{{ item.option }}" + value: "{{ item.value }}" + state: "{{ 'present' if resolv_is_managed | bool else 'absent' }}" + loop: + - option: dns + value: none + - option: rc-manager + value: unmanaged + when: + - ansible_facts.os_family == "RedHat" and ansible_facts.distribution_major_version | int >= 9 + register: dns_config_task + +- name: Reload NetworkManager with DNS config + become: true + systemd: + name: NetworkManager + state: reloaded + daemon_reload: true + when: dns_config_task is changed + +- name: Generate nmstate desired state + set_fact: + network_nmstate_desired_state: "{{ network_interfaces | nmstate_config }}" + +- name: Apply nmstate configuration using module + nmstate_apply: + state: "{{ network_nmstate_desired_state }}" + register: nmstate_apply_result + become: true + vars: + ansible_python_interpreter: "{{ ansible_facts.python.executable }}" + +- name: Initialise nmstate firewalld interface-zone map + set_fact: + network_nmstate_zone_map: {} + +- name: Build nmstate firewalld interface-zone map + set_fact: + network_nmstate_zone_map: >- + {{ network_nmstate_zone_map + | combine({ (item | net_interface): (item | net_zone) }) }} + loop: "{{ network_interfaces | default([]) }}" + when: + - item | net_zone + - item | net_interface + +- name: Build nmstate firewalld interface-zone items + set_fact: + network_nmstate_zone_items: >- + {{ network_nmstate_zone_map + | dict2items(key_name='interface', value_name='zone') }} + +- block: + - name: Ensure firewalld package is installed for nmstate zone sync + package: + name: firewalld + state: present + + - name: Ensure firewalld service is enabled and running for nmstate zone sync + service: + name: firewalld + state: started + enabled: true + + # TODO(gkoper): replace temporary nmcli profile zone sync with native + # zone handling in the nmstate filter/module path. + - name: Gather NetworkManager connection firewalld zones for nmstate interfaces + command: + argv: + - nmcli + - -g + - connection.zone + - connection + - show + - "{{ item.interface }}" + changed_when: false + loop: "{{ network_nmstate_zone_items }}" + register: network_nmstate_nm_zone_result + + - name: Ensure NetworkManager connection firewalld zones are set for nmstate interfaces + command: + argv: + - nmcli + - connection + - modify + - "{{ item.item.interface }}" + - connection.zone + - "{{ item.item.zone }}" + loop: "{{ network_nmstate_nm_zone_result.results }}" + when: + - (item.stdout | default('') | trim) != item.item.zone + + # Keep permanent firewalld configuration in sync first. Runtime state is + # refreshed separately below from permanent config. + - name: Ensure firewalld zones exist for nmstate interfaces + firewalld: + offline: true + permanent: true + state: present + zone: "{{ item }}" + loop: "{{ network_nmstate_zone_items | map(attribute='zone') | unique | list }}" + register: network_nmstate_zones_result + + - name: Ensure permanent firewalld zones are set for nmstate interfaces + firewalld: + interface: "{{ item.interface }}" + offline: true + permanent: true + state: enabled + zone: "{{ item.zone }}" + loop: "{{ network_nmstate_zone_items }}" + register: network_nmstate_perm_result + + - name: Reload firewalld runtime from permanent config before nmstate zone sync + ansible.builtin.service: + name: firewalld + state: reloaded + changed_when: false + when: + - network_nmstate_zones_result is changed or network_nmstate_perm_result is changed + + # TODO(gkoper): investigate NM profile zone mapping to avoid explicit + # firewalld sync in nmstate path. + - name: Ensure runtime firewalld zones are set for nmstate interfaces + firewalld: + interface: "{{ item.interface }}" + immediate: true + permanent: false + state: enabled + zone: "{{ item.zone }}" + loop: "{{ network_nmstate_zone_items }}" + become: true + when: + - ansible_facts.os_family == "RedHat" + - firewalld_enabled | default(false) | bool + - network_nmstate_zone_items | length > 0 diff --git a/ansible/roles/network-nmstate/vars/RedHat.yml b/ansible/roles/network-nmstate/vars/RedHat.yml new file mode 100644 index 000000000..d5f3312e8 --- /dev/null +++ b/ansible/roles/network-nmstate/vars/RedHat.yml @@ -0,0 +1,4 @@ +--- +network_nmstate_packages: + - nmstate + - python3-libnmstate diff --git a/doc/source/configuration/reference/network.rst b/doc/source/configuration/reference/network.rst index 9e6789006..721ee6aee 100644 --- a/doc/source/configuration/reference/network.rst +++ b/doc/source/configuration/reference/network.rst @@ -71,14 +71,14 @@ supported: ``rules`` List of IP routing rules. - On CentOS or Rocky, each item should be a string describing an ``iproute2`` - IP routing rule. - - On Ubuntu, each item should be a dict containing optional items ``from``, - ``to``, ``priority`` and ``table``. ``from`` is the source address prefix - to match with optional prefix. ``to`` is the destination address prefix to - match with optional prefix. ``priority`` is the priority of the rule. - ``table`` is the routing table ID. + The required format depends on the network engine: + + * ``nmstate`` engine: each rule must be a dict. + * ``default`` engine on CentOS/Rocky: each rule may be a string or dict. + * ``default`` engine on Ubuntu (systemd-networkd): each rule must be a dict. + + Dict rules support optional keys ``from``, ``to``, ``priority``, and + ``table``. ``physical_network`` Name of the physical network on which this network exists. This aligns with the physical network concept in neutron. This may be used to customise the @@ -91,6 +91,160 @@ supported: Whether to allocate an IP address for this network. If set to ``true``, an IP address will not be allocated. +.. _configuration-network-engines: + +Network Configuration Engines +============================== + +Kayobe supports multiple network configuration engines to manage networking on +bare metal hosts. + +Available Engines +----------------- + +``default`` (default) + Uses OS-specific networking tools: + + - RedHat/Rocky Linux: ``MichaelRigart.interfaces`` role with ifcfg files + - Debian/Ubuntu: systemd-networkd with .network files + +``nmstate`` + Uses NetworkManager and nmstate for unified, declarative network + configuration. **Supported on Rocky Linux only.** + +Configuring the Network Engine +------------------------------ + +Set the engine in ``${KAYOBE_CONFIG_PATH}/globals.yml``: + +.. code-block:: yaml + :caption: ``globals.yml`` + + network_engine: nmstate # Valid: "default" (default) or "nmstate" + +.. note:: + + The nmstate engine is only supported on Rocky Linux. + For Ubuntu Noble, use the ``default`` engine (default). + +Nmstate Engine Features +------------------------- + +**Structured ethtool configuration** via ``_ethtool_config``: + +.. code-block:: yaml + :caption: ``networks.yml`` + + tenant_ethtool_config: + ring: + rx: 4096 + tx: 2048 + feature: + rx: true # rx-checksum + gso: false # tx-generic-segmentation + hw-tc-offload: true + +Supported ethtool features: + +- **Ring**: ``rx``, ``tx``, ``rx-max``, ``tx-max``, ``rx-jumbo``, ``rx-mini`` +- **Hardware offloads**: ``rx-checksum`` (alias: ``rx``), ``tx-checksum-ip-generic``, + ``rx-gro`` (alias: ``gro``), ``tx-generic-segmentation`` (alias: ``gso``), + ``rx-lro`` (alias: ``lro``), ``hw-tc-offload`` + +**Breaking change**: nmstate uses structured YAML instead of +``_ethtool_opts`` command strings. Default engine preserves existing +behavior. + +**Multiple IP addresses** on a single physical interface are natively supported. + +Advanced: Configuring Interface Types +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +By default, nmstate automatically determines interface types based on +configuration: bridges are ``linux-bridge``, bonds are ``bond``, VLANs are +``vlan``, and interfaces matching ``dummy.*`` are ``dummy``. Other standalone +interfaces default to ``ethernet``. + +For testing or advanced scenarios, you can explicitly configure interface +types using the following attributes. Explicit type settings override +automatic inference. + +``_type`` + Explicit interface type for the network's main interface. + Default: ``dummy`` for interface names matching ``dummy.*``, otherwise + ``ethernet``. + + Supported values: ``ethernet``, ``dummy``, ``veth``, and other interface types + supported by nmstate. Use ``dummy`` for virtual test interfaces. + + .. code-block:: yaml + :caption: ``inventory/group_vars//network-interfaces`` + + # Example: Configure a dummy interface for testing + test_net_interface: dummy2 + test_net_type: dummy + +``_port_type_`` + Explicit type for a specific bridge port. + Default: ``dummy`` for port names matching ``dummy.*``, otherwise + ``ethernet``. + + Used when a bridge port should be a non-ethernet interface type (e.g., ``dummy``, + ``veth``). The ```` must match a port listed in ``_bridge_ports``. + + .. code-block:: yaml + :caption: ``inventory/group_vars//network-interfaces`` + + # Example: Bridge with dummy ports for testing + admin_interface: br0 + admin_bridge_ports: + - dummy3 + - dummy4 + admin_port_type_dummy3: dummy + admin_port_type_dummy4: dummy + +``_slave_type_`` + Explicit type for a specific bond slave. + Default: ``dummy`` for slave names matching ``dummy.*``, otherwise + ``ethernet``. + + Used when a bond slave should be a non-ethernet interface type (e.g., ``dummy``, + ``veth``). The ```` must match a slave listed in ``_bond_slaves``. + + .. code-block:: yaml + :caption: ``inventory/group_vars//network-interfaces`` + + # Example: Bond with dummy slaves for testing + internal_interface: bond0 + internal_bond_slaves: + - dummy5 + - dummy6 + internal_slave_type_dummy5: dummy + internal_slave_type_dummy6: dummy + +.. note:: + + These options are primarily used for testing environments with virtual interfaces. + Production environments typically use physical ethernet interfaces and do not require + explicit type configuration. + +Prerequisites +------------- + +**Operating System Support:** + +The nmstate engine is **only supported on Rocky Linux**. + +Ubuntu Noble is **not supported** because the required system packages +(nmstate, python3-libnmstate) are not available in Ubuntu repositories. +For Ubuntu Noble, use the ``default`` network engine which provides +robust networking configuration via systemd-networkd. + +**Dependencies:** + +- NetworkManager service running and enabled +- nmstate and python3-libnmstate packages (automatically installed on Rocky Linux) + Configuring an IP Subnet ------------------------ @@ -275,9 +429,12 @@ Configuring IP Routing Policy Rules ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ IP routing policy rules may be configured by setting the ``rules`` attribute -for a network to a list of rules. Two formats are supported for defining rules: -string format and dict format. String format rules are only supported on -CentOS Stream and Rocky Linux systems. +for a network to a list of rules. Two formats are available (dict and string), +but support depends on the network engine: + +* ``nmstate`` engine: dict format only. +* ``default`` engine on CentOS Stream/Rocky Linux: dict and string format. +* ``default`` engine on Ubuntu (systemd-networkd): dict format only. Dict format rules """"""""""""""""" @@ -299,8 +456,8 @@ handle traffic from the subnet ``10.1.0.0/24`` using the routing table These rules will be configured on all hosts to which the network is mapped. -String format rules (CentOS Stream/Rocky Linux only) -"""""""""""""""""""""""""""""""""""""""""""""""""""" +String format rules (default engine on CentOS Stream/Rocky Linux only) +"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" The string format of a rule is the string which would be appended to ``ip rule `` to create or delete the rule. Note that when using NetworkManager @@ -404,7 +561,23 @@ The following attributes are supported: Enable or disable the Spanning Tree Protocol (STP) on this bridge. Should be set to a boolean value. The default is not set on Ubuntu systems. ``bond_mode`` - For bond interfaces, the bond's mode, e.g. 802.3ad. + For bond interfaces, the bond's mode, e.g. ``802.3ad``, ``balance-rr``, + ``active-backup``. + + **nmstate engine**: If not specified, defaults to ``balance-rr`` (round-robin + load balancing). This mode works without special switch configuration and is + suitable for most development/test environments. + + **Production recommendation**: Explicitly configure bond mode based on your + network requirements. Common modes: + + - ``802.3ad``: IEEE 802.3ad LACP (requires switch configuration) + - ``balance-rr``: Round-robin (no switch config needed) + - ``active-backup``: Active-backup failover + - ``balance-xor``: XOR hash-based load balancing + + See `Linux bonding documentation `_ + for complete mode descriptions. ``bond_ad_select`` For bond interfaces, the 802.3ad aggregation selection logic to use. Valid values are ``stable`` (default selection logic if not configured), diff --git a/etc/kayobe/globals.yml b/etc/kayobe/globals.yml index 498b5886b..bcf9aca4e 100644 --- a/etc/kayobe/globals.yml +++ b/etc/kayobe/globals.yml @@ -73,6 +73,13 @@ # user would not normally have permission to create. Default is true. #kayobe_control_host_become: +############################################################################### +# Networking configuration. + +# Network configuration engine. Valid options are "default" and "nmstate". +# Default is "default". +#network_engine: default + ############################################################################### # Dummy variable to allow Ansible to accept this file. workaround_ansible_issue_8743: yes diff --git a/kayobe/plugins/filter/nmstate.py b/kayobe/plugins/filter/nmstate.py new file mode 100644 index 000000000..54e4c8155 --- /dev/null +++ b/kayobe/plugins/filter/nmstate.py @@ -0,0 +1,626 @@ +# Copyright (c) 2026 StackHPC Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import re + +import jinja2 +from kayobe.plugins.filter import networks +from kayobe.plugins.filter import utils + + +def _get_ip_config(context, name, inventory_hostname, defroute=None): + ip = networks.net_ip(context, name, inventory_hostname) + bootproto = networks.net_bootproto(context, name, inventory_hostname) + prefix = networks.net_prefix(context, name, inventory_hostname) + + config = {"enabled": False} + if bootproto == "dhcp": + config = {"enabled": True, "dhcp": True} + if defroute is False: + config["auto-routes"] = False + elif ip: + address = {"ip": ip} + if prefix: + address["prefix-length"] = int(prefix) + config = { + "enabled": True, + "dhcp": False, + "address": [address] + } + + return config + + +# Ethtool feature aliases for user convenience (documented aliases only) +ETHTOOL_FEATURE_ALIASES = { + 'rx': 'rx-checksum', + 'gro': 'rx-gro', + 'gso': 'tx-generic-segmentation', + 'lro': 'rx-lro' +} + +# Tier 1 supported ethtool features (most critical performance features) +TIER1_ETHTOOL_FEATURES = { + 'rx-checksum', # Receive checksum offload + 'tx-checksum-ip-generic', # Transmit checksum offload + 'rx-gro', # Generic Receive Offload + 'tx-generic-segmentation', # Generic Segmentation Offload + 'rx-lro', # Large Receive Offload + 'hw-tc-offload' # Hardware traffic control offload +} + +# Supported ring buffer parameters +SUPPORTED_RING_PARAMS = { + 'rx', 'tx', 'rx-max', 'tx-max', 'rx-jumbo', 'rx-mini' +} + + +def _resolve_ethtool_feature_aliases(features): + """Convert ethtool feature aliases to canonical names. + + Args: + features (dict): Feature configuration with possible aliases + + Returns: + dict: Features with aliases resolved to canonical names + + Raises: + ValueError: If feature contains invalid values + """ + if not isinstance(features, dict): + raise ValueError("Ethtool features must be a dictionary") + + resolved = {} + for key, value in features.items(): + if not isinstance(value, bool): + raise ValueError( + f"Ethtool feature '{key}' must be boolean, " + f"got {type(value).__name__}") + + canonical_name = ETHTOOL_FEATURE_ALIASES.get(key, key) + resolved[canonical_name] = value + + return resolved + + +def _validate_ethtool_features(features): + """Validate ethtool features against Tier 1 supported features. + + Args: + features (dict): Resolved feature configuration + + Returns: + dict: Validated features + + Raises: + ValueError: If unsupported features are specified + """ + unsupported = set(features.keys()) - TIER1_ETHTOOL_FEATURES + if unsupported: + supported_list = ', '.join(sorted(TIER1_ETHTOOL_FEATURES)) + alias_list = ', '.join( + f"{alias} -> {canonical}" + for alias, canonical in ETHTOOL_FEATURE_ALIASES.items()) + raise ValueError( + f"Unsupported ethtool features: {', '.join(sorted(unsupported))}. " + f"Tier 1 supported features: {supported_list}. " + f"Supported aliases: {alias_list}") + + return features + + +def _validate_ethtool_ring(ring_config): + """Validate ethtool ring buffer configuration. + + Args: + ring_config (dict): Ring buffer configuration + + Returns: + dict: Validated ring configuration + + Raises: + ValueError: If invalid ring parameters are specified + """ + if not isinstance(ring_config, dict): + raise ValueError("Ethtool ring configuration must be a dictionary") + + unsupported = set(ring_config.keys()) - SUPPORTED_RING_PARAMS + if unsupported: + supported_list = ', '.join(sorted(SUPPORTED_RING_PARAMS)) + raise ValueError( + f"Unsupported ring parameters: {', '.join(sorted(unsupported))}. " + f"Supported parameters: {supported_list}") + + # Validate values are positive integers + for param, value in ring_config.items(): + if not isinstance(value, int) or value < 0: + raise ValueError( + f"Ring parameter '{param}' must be a non-negative integer, " + f"got {value}") + + return ring_config + + +def _process_ethtool_config(ethtool_config): + """Process structured ethtool configuration into nmstate format. + + Args: + ethtool_config (dict): Structured ethtool configuration + + Returns: + dict: nmstate-compatible ethtool configuration + + Raises: + ValueError: If configuration is invalid + """ + if not isinstance(ethtool_config, dict): + raise ValueError("Ethtool configuration must be a dictionary") + + nmstate_ethtool = {} + + # Process ring buffer configuration + if 'ring' in ethtool_config: + validated_ring = _validate_ethtool_ring(ethtool_config['ring']) + nmstate_ethtool['ring'] = validated_ring + + # Process feature configuration with alias resolution and validation + if 'feature' in ethtool_config: + features = ethtool_config['feature'] + resolved_features = _resolve_ethtool_feature_aliases(features) + validated_features = _validate_ethtool_features(resolved_features) + nmstate_ethtool['feature'] = validated_features + + return nmstate_ethtool + + +def _disable_ip_config(iface): + iface["ipv4"] = {"enabled": False} + iface["ipv6"] = {"enabled": False} + + +def _port_name(port): + if isinstance(port, dict): + return port.get("name") + return port + + +def _default_iface_type(iface_name): + # Match MichaelRigart.interfaces dummy_interface_regex: 'dummy.*'. + if isinstance(iface_name, str) and re.match(r"dummy.*", iface_name): + return "dummy" + return "ethernet" + + +def _parse_route_options(context, name, index, route): + supported_keys = { + "cidr", + "gateway", + "metric", + "onlink", + "options", + "src", + "table", + } + unsupported_keys = sorted(set(route) - supported_keys) + if unsupported_keys: + raise ValueError( + f"Network '{name}' has unsupported routing route keys at " + f"index {index} for the nmstate engine: " + f"{', '.join(unsupported_keys)}") + + route_config = {} + + if route.get("metric") is not None: + route_config["metric"] = int(route["metric"]) + if route.get("src") is not None: + route_config["source"] = route["src"] + if route.get("onlink") is not None: + route_config["on-link"] = utils.call_bool_filter( + context, route["onlink"]) + + options = route.get("options") + if options is None: + return route_config + if not isinstance(options, list): + raise ValueError( + f"Network '{name}' has invalid routing route options format at " + f"index {index} for the nmstate engine. Route options must be " + "a list.") + + for option in options: + if not isinstance(option, str): + raise ValueError( + f"Network '{name}' has invalid routing route option at " + f"index {index} for the nmstate engine. Route options must " + "be strings.") + + option_key = None + option_value = None + if option == "onlink": + option_key = "on-link" + option_value = True + elif option.startswith("metric "): + option_key = "metric" + option_value = int(option.split(None, 1)[1]) + elif option.startswith("src "): + option_key = "source" + option_value = option.split(None, 1)[1] + else: + raise ValueError( + f"Network '{name}' has unsupported routing route option at " + f"index {index} for the nmstate engine: '{option}'") + + existing_value = route_config.get(option_key) + if existing_value is not None and existing_value != option_value: + raise ValueError( + f"Network '{name}' has conflicting routing route option at " + f"index {index} for the nmstate engine: '{option}'") + route_config[option_key] = option_value + + return route_config + + +def _get_bond_options(context, name, inventory_hostname): + bond_option_map = { + "bond_ad_select": "ad_select", + "bond_downdelay": "downdelay", + "bond_lacp_rate": "lacp_rate", + "bond_miimon": "miimon", + "bond_updelay": "updelay", + "bond_xmit_hash_policy": "xmit_hash_policy", + } + + bond_options = {} + for attr, option_name in bond_option_map.items(): + value = networks.net_attr(context, name, attr, inventory_hostname) + if value is not None: + bond_options[option_name] = value + + return bond_options + + +@jinja2.pass_context +def nmstate_config(context, names, inventory_hostname=None): + interfaces = {} + routes = [] + rules = [] + + # Get routing table name to ID mapping + route_tables_list = utils.get_hostvar( + context, "network_route_tables", inventory_hostname) + route_tables = {} + if route_tables_list: + route_tables = {table["name"]: table["id"] + for table in route_tables_list} + + def get_iface(name): + if name not in interfaces: + interfaces[name] = {"name": name, "state": "up"} + return interfaces[name] + + for name in names: + iface_name = networks.net_interface(context, name, inventory_hostname) + if not iface_name: + continue + + iface = get_iface(iface_name) + + mtu = networks.net_mtu(context, name, inventory_hostname) + if mtu: + iface["mtu"] = mtu + + # IP Configuration. nmstate supports multiple addresses, but Kayobe + # usually defines one per network. + defroute = networks.net_defroute( + context, name, inventory_hostname) + if defroute is not None: + defroute = utils.call_bool_filter(context, defroute) + + ipv4_config = _get_ip_config( + context, name, inventory_hostname, defroute) + if ipv4_config.get("enabled"): + if "ipv4" not in iface or not iface["ipv4"].get("enabled"): + iface["ipv4"] = ipv4_config + elif not ipv4_config.get("dhcp"): + addresses = ipv4_config["address"] + iface["ipv4"].setdefault("address", []).extend( + addresses) + + # Gateway - only add if defroute allows it + gateway = networks.net_gateway( + context, name, inventory_hostname) + if gateway: + # Respect defroute: only add default route if defroute + # is None (default) or True + if defroute is None or defroute: + routes.append({ + "destination": "0.0.0.0/0", + "next-hop-address": gateway, + "next-hop-interface": iface_name + }) + + # Routes and Rules + net_routes = networks.net_routes(context, name, inventory_hostname) + for i, route in enumerate(net_routes or []): + if not isinstance(route, dict): + raise ValueError( + f"Network '{name}' has invalid routing route format at " + f"index {i} for the nmstate engine. Routes must use dict " + "format. String format routes are only supported by the " + "default network engine.") + route_config = { + "destination": route["cidr"], + "next-hop-address": route.get("gateway"), + "next-hop-interface": iface_name, + } + route_config.update( + _parse_route_options(context, name, i, route)) + table = route.get("table") + if table is not None: + # Look up table name in mapping, or use value if numeric + table_id = route_tables.get(table, table) + # Ensure table_id is an integer + if isinstance(table_id, str): + if table_id.isdigit(): + table_id = int(table_id) + else: + raise ValueError( + f"Routing table '{table}' is not defined in " + f"network_route_tables and is not a valid " + f"numeric table ID") + route_config["table-id"] = int(table_id) + routes.append(route_config) + + net_rules = networks.net_rules(context, name, inventory_hostname) + for rule in net_rules or []: + if not isinstance(rule, dict): + raise ValueError( + f"Network '{name}' has invalid routing rule format for " + "the nmstate engine. Rules must use dict format " + "(keys: from, to, priority, table). String format rules " + "are only supported by the default network engine.") + rule_config = {} + + if rule.get("from") is not None: + rule_config["ip-from"] = rule["from"] + if rule.get("to") is not None: + rule_config["ip-to"] = rule["to"] + + priority_value = rule.get("priority") + if priority_value is not None: + rule_config["priority"] = int(priority_value) + + table = rule.get("table") + if table is not None: + # Look up table name in mapping, or use value if numeric + table_id = route_tables.get(table, table) + # Ensure table_id is an integer + if isinstance(table_id, str): + if table_id.isdigit(): + table_id = int(table_id) + else: + raise ValueError( + f"Routing table '{table}' is not defined in " + f"network_route_tables and is not a valid " + f"numeric table ID") + rule_config["route-table"] = int(table_id) + + rules.append(rule_config) + + # Specific Interface Types + if networks.net_is_bridge(context, name, inventory_hostname): + iface["type"] = "linux-bridge" + br_ports = networks.net_bridge_ports( + context, name, inventory_hostname) + stp = networks.net_bridge_stp( + context, name, inventory_hostname) + + bridge_config = {} # type: dict[str, object] + bridge_config["port"] = [{"name": p} for p in br_ports or []] + + # Only configure STP when explicitly set + if stp is not None: + stp_enabled = stp == "true" + bridge_config["options"] = { + "stp": {"enabled": stp_enabled}} + iface["bridge"] = bridge_config + else: + iface["bridge"] = bridge_config + + # Ensure ports are initialized if not otherwise defined. + # Check for explicit type configuration via + # _port_type_. + for port in br_ports or []: + port_iface = get_iface(port) + if "type" not in port_iface: + # Check for explicit type configuration + port_type = networks.net_attr( + context, name, f"port_type_{port}", + inventory_hostname) + port_iface["type"] = ( + port_type if port_type else _default_iface_type(port)) + + elif networks.net_is_bond(context, name, inventory_hostname): + iface["type"] = "bond" + slaves = networks.net_bond_slaves( + context, name, inventory_hostname) + mode = networks.net_bond_mode(context, name, inventory_hostname) + link_agg_config = {"port": slaves or []} + if mode is not None: + link_agg_config["mode"] = mode + else: + # nmstate requires bond mode. Provide a sensible default. + # balance-rr (round-robin) works in most environments without + # requiring switch configuration. + link_agg_config["mode"] = "balance-rr" + + bond_options = _get_bond_options( + context, name, inventory_hostname) + if bond_options: + link_agg_config["options"] = bond_options + + iface["link-aggregation"] = link_agg_config + # Ensure slaves are initialized if not otherwise defined. + # Check for explicit type configuration via + # _slave_type_. + for slave in slaves or []: + slave_iface = get_iface(slave) + if "type" not in slave_iface: + # Check for explicit type configuration + slave_type = networks.net_attr( + context, name, f"slave_type_{slave}", + inventory_hostname) + slave_iface["type"] = ( + slave_type + if slave_type else _default_iface_type(slave)) + + elif networks.net_is_vlan_interface( + context, name, inventory_hostname): + iface["type"] = "vlan" + vlan_id = networks.net_vlan( + context, name, inventory_hostname) + parent = networks.net_parent( + context, name, inventory_hostname) + + # Derive VLAN ID from interface name if not explicitly + # set + if vlan_id is None: + vlan_match = re.match( + r"^[a-zA-Z0-9_\-]+\.([1-9][\d]{0,3})$", + iface_name) + if vlan_match: + vlan_id = vlan_match.group(1) + else: + # Skip VLAN config if we can't derive the ID + continue + + # Derive parent interface if not explicitly set + if not parent: + parent = re.sub( + r'\.{}$'.format(vlan_id), '', iface_name) + + iface["vlan"] = { + "base-iface": parent, + "id": int(vlan_id) + } + # Ensure parent is initialized + get_iface(parent) + + else: + if "type" not in iface: + # Check for explicit type configuration via _type + iface_type = networks.net_attr( + context, name, "type", inventory_hostname) + iface["type"] = ( + iface_type + if iface_type else _default_iface_type(iface_name)) + + # Process structured ethtool configuration for advanced tuning + ethtool_config = networks.net_attr( + context, name, "ethtool_config", inventory_hostname) + if ethtool_config: + try: + processed_config = _process_ethtool_config(ethtool_config) + if processed_config: + iface["ethtool"] = processed_config + except ValueError as e: + raise ValueError( + f"Invalid ethtool configuration for network " + f"'{name}': {e}") + + # Configure virtual Ethernet patch links to connect Linux bridges that + # carry provision/cleaning/external networks into OVS. + for veth in networks.get_ovs_veths(context, names, inventory_hostname): + bridge_name = veth["bridge"] + phy_name = veth["name"] + peer_name = veth["peer"] + + bridge_iface = get_iface(bridge_name) + bridge_iface["type"] = "linux-bridge" + bridge_config = bridge_iface.get("bridge") + if not isinstance(bridge_config, dict): + bridge_config = {} + bridge_iface["bridge"] = bridge_config + + bridge_ports = bridge_config.get("port") + if not isinstance(bridge_ports, list): + bridge_ports = [] + bridge_config["port"] = bridge_ports + + normalized_bridge_ports = [] + for port in bridge_ports: + port_name = _port_name(port) + if port_name: + normalized_bridge_ports.append({"name": str(port_name)}) + + bridge_config["port"] = normalized_bridge_ports + bridge_ports = normalized_bridge_ports + + if not any(_port_name(port) == phy_name for port in bridge_ports): + bridge_ports.append({"name": phy_name}) + + phy_iface = get_iface(phy_name) + phy_iface["type"] = "veth" + phy_iface["veth"] = {"peer": peer_name} + if veth.get("mtu"): + phy_iface["mtu"] = veth["mtu"] + _disable_ip_config(phy_iface) + + peer_iface = get_iface(peer_name) + peer_iface["type"] = "veth" + peer_iface["veth"] = {"peer": phy_name} + if veth.get("mtu"): + peer_iface["mtu"] = veth["mtu"] + _disable_ip_config(peer_iface) + + # Filter routes that have next-hop information + valid_routes = [] + for route in routes: + if not isinstance(route, dict): + continue + has_next_hop_address = route.get("next-hop-address") is not None + has_next_hop_interface = route.get("next-hop-interface") is not None + has_next_hop = has_next_hop_address or has_next_hop_interface + if has_next_hop: + valid_routes.append(route) + + # Sort interfaces to ensure dependencies come before dependents. + # Bridges must come after their ports, VLANs after their base interfaces. + def interface_sort_key(iface): + iface_type = iface.get("type", "ethernet") + # Process in order: ethernet, bond, veth, vlan, then bridges + # This ensures ports exist before bridges that use them + type_order = { + "dummy": 0, # Dummy interfaces are base types like ethernet + "ethernet": 0, + "bond": 1, + "veth": 2, + "vlan": 3, + "linux-bridge": 4, + } + return type_order.get(iface_type, 5) + + sorted_interfaces = sorted(interfaces.values(), key=interface_sort_key) + + return { + "interfaces": sorted_interfaces, + "routes": {"config": valid_routes}, + "route-rules": {"config": rules} + } + + +def get_filters(): + return { + "nmstate_config": nmstate_config, + } diff --git a/kayobe/tests/unit/plugins/filter/test_nmstate.py b/kayobe/tests/unit/plugins/filter/test_nmstate.py new file mode 100644 index 000000000..720200ec8 --- /dev/null +++ b/kayobe/tests/unit/plugins/filter/test_nmstate.py @@ -0,0 +1,1014 @@ +# Copyright (c) 2026 StackHPC Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import jinja2 +import unittest + +from kayobe.plugins.filter import nmstate + + +class TestNMStateFilter(unittest.TestCase): + + maxDiff = 2000 + + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + # net1: Ethernet on eth0 with IP 1.2.3.4/24. + "net1_interface": "eth0", + "net1_ips": {"test-host": "1.2.3.4"}, + "net1_cidr": "1.2.3.4/24", + "net1_gateway": "1.2.3.1", + "net1_mtu": 1500, + "net1_ethtool_config": { + "ring": {"rx": 2048, "tx": 2048}, + "feature": {"rx": True, "gso": False} + }, + # net2: VLAN on eth0.2 with VLAN 2 on interface eth0. + "net2_interface": "eth0.2", + "net2_vlan": 2, + "net2_ips": {"test-host": "1.2.4.4"}, + "net2_cidr": "1.2.4.4/24", + # net3: bridge on br0 with ports eth1. + "net3_interface": "br0", + "net3_bridge_ports": ['eth1'], + "net3_bridge_stp": True, + # net4: bond on bond0 with slaves eth2 and eth3. + "net4_interface": "bond0", + "net4_bond_slaves": ['eth2', 'eth3'], + "net4_bond_mode": "layer3+4", + "net4_bond_miimon": 100, + "net4_bond_updelay": 200, + "net4_bond_downdelay": 300, + "net4_bond_xmit_hash_policy": "layer3+4", + "net4_bond_lacp_rate": 1, + "net4_bond_ad_select": "bandwidth", + # net5 & net6: Multiple networks on the same interface eth4 + "net5_interface": "eth4", + "net5_ips": {"test-host": "10.0.0.1"}, + "net5_cidr": "10.0.0.1/24", + "net6_interface": "eth4", + "net6_ips": {"test-host": "10.0.0.2"}, + "net6_cidr": "10.0.0.2/24", + } + + def setUp(self): + self.env = jinja2.Environment(autoescape=True) + self.env.filters["bool"] = self._jinja2_bool + self.context = self._make_context(self.variables) + + def _jinja2_bool(self, value): + if isinstance(value, bool): + return value + if str(value).lower() in ("true", "yes", "on", "1"): + return True + return False + + def _make_context(self, parent): + return self.env.context_class( + self.env, parent=parent, name='dummy', blocks={}) + + def test_nmstate_config_ethernet(self): + result = nmstate.nmstate_config(self.context, ["net1"]) + expected_iface = { + "name": "eth0", + "state": "up", + "type": "ethernet", + "mtu": 1500, + "ipv4": { + "enabled": True, + "dhcp": False, + "address": [{"ip": "1.2.3.4", "prefix-length": 24}] + }, + "ethtool": { + "ring": { + "rx": 2048, + "tx": 2048 + }, + "feature": { + "rx-checksum": True, + "tx-generic-segmentation": False + } + } + } + self.assertIn(expected_iface, result["interfaces"]) + route = result["routes"]["config"][0] + self.assertEqual(route["next-hop-address"], "1.2.3.1") + + def test_nmstate_config_vlan(self): + result = nmstate.nmstate_config(self.context, ["net2"]) + # Should have eth0 and eth0.2 + ifnames = [i["name"] for i in result["interfaces"]] + self.assertIn("eth0", ifnames) + self.assertIn("eth0.2", ifnames) + + vlan_iface = next(i for i in result["interfaces"] + if i["name"] == "eth0.2") + self.assertEqual(vlan_iface["type"], "vlan") + self.assertEqual(vlan_iface["vlan"]["base-iface"], "eth0") + self.assertEqual(vlan_iface["vlan"]["id"], 2) + + def test_nmstate_config_bridge(self): + result = nmstate.nmstate_config(self.context, ["net3"]) + br_iface = next(i for i in result["interfaces"] if i["name"] == "br0") + self.assertEqual(br_iface["type"], "linux-bridge") + self.assertEqual(br_iface["bridge"]["port"], [{"name": "eth1"}]) + self.assertTrue(br_iface["bridge"]["options"]["stp"]["enabled"]) + + # eth1 should be present as ethernet + eth1_iface = next(i for i in result["interfaces"] + if i["name"] == "eth1") + self.assertEqual(eth1_iface["type"], "ethernet") + + def test_nmstate_config_bond(self): + result = nmstate.nmstate_config(self.context, ["net4"]) + bond_iface = next(i for i in result["interfaces"] + if i["name"] == "bond0") + self.assertEqual(bond_iface["type"], "bond") + self.assertEqual(bond_iface["link-aggregation"]["mode"], "layer3+4") + self.assertEqual( + bond_iface["link-aggregation"]["options"], + { + "ad_select": "bandwidth", + "downdelay": 300, + "lacp_rate": 1, + "miimon": 100, + "updelay": 200, + "xmit_hash_policy": "layer3+4", + } + ) + ports = set(bond_iface["link-aggregation"]["port"]) + self.assertEqual(ports, {"eth2", "eth3"}) + + def test_nmstate_config_multiple_nets_same_iface(self): + result = nmstate.nmstate_config(self.context, ["net5", "net6"]) + eth4_iface = next(i for i in result["interfaces"] + if i["name"] == "eth4") + self.assertEqual(eth4_iface["type"], "ethernet") + addresses = eth4_iface["ipv4"]["address"] + self.assertEqual(len(addresses), 2) + ips = {a["ip"] for a in addresses} + self.assertEqual(ips, {"10.0.0.1", "10.0.0.2"}) + + def test_nmstate_config_dummy_interface_infers_dummy_type(self): + variables = { + "inventory_hostname": "test-host", + "test_interface": "dummy2", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + iface = next(i for i in result["interfaces"] if i["name"] == "dummy2") + self.assertEqual(iface["type"], "dummy") + + def test_nmstate_config_dummy_interface_explicit_type_override(self): + variables = { + "inventory_hostname": "test-host", + "test_interface": "dummy2", + "test_type": "ethernet", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + iface = next(i for i in result["interfaces"] if i["name"] == "dummy2") + self.assertEqual(iface["type"], "ethernet") + + def test_nmstate_config_bridge_ports_infer_dummy_type(self): + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "test_interface": "br0", + "test_bridge_ports": ["dummy3", "dummy4"], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + dummy3 = next(i for i in result["interfaces"] if i["name"] == "dummy3") + dummy4 = next(i for i in result["interfaces"] if i["name"] == "dummy4") + self.assertEqual(dummy3["type"], "dummy") + self.assertEqual(dummy4["type"], "dummy") + + def test_nmstate_config_bridge_port_explicit_type_override(self): + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "test_interface": "br0", + "test_bridge_ports": ["dummy3", "dummy4"], + "test_port_type_dummy3": "ethernet", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + dummy3 = next(i for i in result["interfaces"] if i["name"] == "dummy3") + dummy4 = next(i for i in result["interfaces"] if i["name"] == "dummy4") + self.assertEqual(dummy3["type"], "ethernet") + self.assertEqual(dummy4["type"], "dummy") + + def test_nmstate_config_bond_slaves_infer_dummy_type(self): + variables = { + "inventory_hostname": "test-host", + "test_interface": "bond0", + "test_bond_slaves": ["dummy5", "dummy6"], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + dummy5 = next(i for i in result["interfaces"] if i["name"] == "dummy5") + dummy6 = next(i for i in result["interfaces"] if i["name"] == "dummy6") + self.assertEqual(dummy5["type"], "dummy") + self.assertEqual(dummy6["type"], "dummy") + + def test_nmstate_config_bond_slave_explicit_type_override(self): + variables = { + "inventory_hostname": "test-host", + "test_interface": "bond0", + "test_bond_slaves": ["dummy5", "dummy6"], + "test_slave_type_dummy5": "ethernet", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + dummy5 = next(i for i in result["interfaces"] if i["name"] == "dummy5") + dummy6 = next(i for i in result["interfaces"] if i["name"] == "dummy6") + self.assertEqual(dummy5["type"], "ethernet") + self.assertEqual(dummy6["type"], "dummy") + + def test_ethtool_ring_configuration(self): + """Test structured ethtool ring buffer configuration.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "ring": {"rx": 4096, "tx": 2048, "rx-max": 8192} + } + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + eth_iface = next(i for i in result["interfaces"] + + + if i["name"] == "eth0") + expected_ethtool = { + "ring": {"rx": 4096, "tx": 2048, "rx-max": 8192} + } + self.assertEqual(eth_iface["ethtool"], expected_ethtool) + + def test_ethtool_tier1_features(self): + """Test Tier 1 ethtool features with canonical names.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "feature": { + "rx-checksum": True, + "tx-checksum-ip-generic": False, + "rx-gro": True, + "tx-generic-segmentation": False, + "rx-lro": True, + "hw-tc-offload": True + } + } + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + eth_iface = next(i for i in result["interfaces"] + + + if i["name"] == "eth0") + expected_features = { + "rx-checksum": True, + "tx-checksum-ip-generic": False, + "rx-gro": True, + "tx-generic-segmentation": False, + "rx-lro": True, + "hw-tc-offload": True + } + self.assertEqual(eth_iface["ethtool"]["feature"], expected_features) + + def test_ethtool_feature_aliases(self): + """Test ethtool feature alias resolution.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "feature": { + "rx": True, # alias for rx-checksum + "gro": False, # alias for rx-gro + "gso": True, # alias for tx-generic-segmentation + "lro": False # alias for rx-lro + } + } + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + eth_iface = next(i for i in result["interfaces"] + + + if i["name"] == "eth0") + expected_features = { + "rx-checksum": True, + "rx-gro": False, + "tx-generic-segmentation": True, + "rx-lro": False + } + self.assertEqual(eth_iface["ethtool"]["feature"], expected_features) + + def test_ethtool_combined_config(self): + """Test combined ring and feature configuration.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "ring": {"rx": 1024, "tx": 512}, + "feature": {"rx": True, "gso": False} + } + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + eth_iface = next(i for i in result["interfaces"] + + + if i["name"] == "eth0") + expected_ethtool = { + "ring": {"rx": 1024, "tx": 512}, + "feature": {"rx-checksum": True, "tx-generic-segmentation": False} + } + self.assertEqual(eth_iface["ethtool"], expected_ethtool) + + def test_ethtool_invalid_feature(self): + """Test error handling for unsupported features.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "feature": {"unsupported-feature": True} + } + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn( + "Unsupported ethtool features: unsupported-feature", + str(cm.exception)) + + def test_ethtool_invalid_ring_param(self): + """Test error handling for invalid ring parameters.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "ring": {"invalid-param": 1024} + } + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn( + + "Unsupported ring parameters: invalid-param", str(cm.exception)) + + def test_ethtool_invalid_feature_value(self): + """Test error handling for non-boolean feature values.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "feature": {"rx-checksum": "invalid"} + } + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn( + + "Ethtool feature 'rx-checksum' must be boolean", str(cm.exception)) + + def test_ethtool_invalid_ring_value(self): + """Test error handling for invalid ring values.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "ring": {"rx": -1} + } + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn( + "Ring parameter 'rx' must be a non-negative integer", + str(cm.exception)) + + def test_vlan_interface_naming_heuristic(self): + """Test VLAN ID derivation from interface name. + + Tests derivation without explicit vlan attribute. + """ + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "vlan_interface": "eth0.123", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["vlan"]) + + vlan_iface = next( + i for i in result["interfaces"] + if i["name"] == "eth0.123") + self.assertEqual(vlan_iface["type"], "vlan") + self.assertEqual(vlan_iface["vlan"]["base-iface"], "eth0") + self.assertEqual(vlan_iface["vlan"]["id"], 123) + + def test_vlan_interface_explicit_vlan_and_parent(self): + """Test VLAN with explicit vlan and parent attributes.""" + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "vlan_interface": "custom1", + "vlan_vlan": 100, + "vlan_parent": "eth0", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["vlan"]) + + vlan_iface = next( + i for i in result["interfaces"] + if i["name"] == "custom1") + self.assertEqual(vlan_iface["type"], "vlan") + self.assertEqual(vlan_iface["vlan"]["base-iface"], "eth0") + self.assertEqual(vlan_iface["vlan"]["id"], 100) + + def test_vlan_interface_invalid_name(self): + """Test VLAN with invalid interface name is skipped gracefully.""" + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "vlan_interface": "eth0", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["vlan"]) + + eth_iface = next( + i for i in result["interfaces"] + if i["name"] == "eth0") + self.assertEqual(eth_iface["type"], "ethernet") + + def test_vlan_interface_parent_derivation(self): + """Test VLAN parent derivation from interface name.""" + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "vlan_interface": "bond0.42", + "vlan_vlan": 42, + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["vlan"]) + + vlan_iface = next( + i for i in result["interfaces"] + if i["name"] == "bond0.42") + self.assertEqual(vlan_iface["vlan"]["base-iface"], "bond0") + self.assertEqual(vlan_iface["vlan"]["id"], 42) + + bond_iface = next( + i for i in result["interfaces"] + if i["name"] == "bond0") + self.assertEqual(bond_iface["state"], "up") + + def test_bridge_stp_unset(self): + """Test bridge with unset bridge_stp does not configure STP.""" + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "Debian"}, + "test_interface": "br0", + "test_bridge_ports": ["eth1"], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + br_iface = next(i for i in result["interfaces"] if i["name"] == "br0") + self.assertEqual(br_iface["type"], "linux-bridge") + self.assertEqual(br_iface["bridge"]["port"], [{"name": "eth1"}]) + self.assertNotIn("options", br_iface["bridge"]) + + def test_bridge_stp_true(self): + """Test bridge with STP explicitly enabled.""" + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "test_interface": "br0", + "test_bridge_ports": ["eth1"], + "test_bridge_stp": "true", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + br_iface = next(i for i in result["interfaces"] if i["name"] == "br0") + self.assertTrue(br_iface["bridge"]["options"]["stp"]["enabled"]) + + def test_bridge_stp_false(self): + """Test bridge with STP explicitly disabled.""" + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "test_interface": "br0", + "test_bridge_ports": ["eth1"], + "test_bridge_stp": "false", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + br_iface = next(i for i in result["interfaces"] if i["name"] == "br0") + self.assertFalse(br_iface["bridge"]["options"]["stp"]["enabled"]) + + def test_defroute_false_static_ip(self): + """Test defroute=false suppresses default route for static IP.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ips": {"test-host": "10.0.0.1"}, + "test_cidr": "10.0.0.1/24", + "test_gateway": "10.0.0.254", + "test_defroute": "false", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + eth_iface = next( + i for i in result["interfaces"] + if i["name"] == "eth0") + self.assertEqual( + eth_iface["ipv4"]["address"][0]["ip"], "10.0.0.1") + + default_routes = [ + r for r in result["routes"]["config"] + if r["destination"] == "0.0.0.0/0"] + self.assertEqual(len(default_routes), 0) + + def test_defroute_true_static_ip(self): + """Test defroute=true adds default route for static IP.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ips": {"test-host": "10.0.0.1"}, + "test_cidr": "10.0.0.1/24", + "test_gateway": "10.0.0.254", + "test_defroute": "true", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + default_routes = [ + r for r in result["routes"]["config"] + if r["destination"] == "0.0.0.0/0"] + self.assertEqual(len(default_routes), 1) + self.assertEqual(default_routes[0]["next-hop-address"], + "10.0.0.254") + + def test_defroute_unset_static_ip(self): + """Test defroute unset (None) adds default route for static IP.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ips": {"test-host": "10.0.0.1"}, + "test_cidr": "10.0.0.1/24", + "test_gateway": "10.0.0.254", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + default_routes = [ + r for r in result["routes"]["config"] + if r["destination"] == "0.0.0.0/0"] + self.assertEqual(len(default_routes), 1) + self.assertEqual(default_routes[0]["next-hop-address"], + "10.0.0.254") + + def test_defroute_false_dhcp(self): + """Test defroute=false disables auto-routes for DHCP.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_bootproto": "dhcp", + "test_gateway": "10.0.0.1", + "test_defroute": "false", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + eth_iface = next( + i for i in result["interfaces"] + if i["name"] == "eth0") + self.assertTrue(eth_iface["ipv4"]["dhcp"]) + self.assertFalse(eth_iface["ipv4"]["auto-routes"]) + + default_routes = [ + r for r in result["routes"]["config"] + if r["destination"] == "0.0.0.0/0"] + self.assertEqual(len(default_routes), 0) + + def test_defroute_true_dhcp(self): + """Test defroute=true allows default routes for DHCP.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_bootproto": "dhcp", + "test_gateway": "10.0.0.1", + "test_defroute": "true", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + eth_iface = next( + i for i in result["interfaces"] + if i["name"] == "eth0") + self.assertTrue(eth_iface["ipv4"]["dhcp"]) + self.assertNotIn("auto-routes", eth_iface["ipv4"]) + + default_routes = [ + r for r in result["routes"]["config"] + if r["destination"] == "0.0.0.0/0"] + self.assertEqual(len(default_routes), 1) + self.assertEqual(default_routes[0]["next-hop-address"], + "10.0.0.1") + + def test_ovs_patch_links(self): + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "net3_interface": "br0", + "net3_bridge_ports": ["eth1"], + "provision_wl_net_name": "net3", + "network_patch_prefix": "p-", + "network_patch_suffix_phy": "-phy", + "network_patch_suffix_ovs": "-ovs", + } + context = self._make_context(variables) + + result = nmstate.nmstate_config(context, ["net3"]) + interfaces = {iface["name"]: iface for iface in result["interfaces"]} + + self.assertIn("p-br0-phy", interfaces) + self.assertIn("p-br0-ovs", interfaces) + self.assertEqual(interfaces["p-br0-phy"]["type"], "veth") + self.assertEqual( + interfaces["p-br0-phy"]["veth"], + {"peer": "p-br0-ovs"} + ) + self.assertEqual(interfaces["p-br0-phy"]["ipv4"], {"enabled": False}) + self.assertEqual(interfaces["p-br0-phy"]["ipv6"], {"enabled": False}) + + self.assertEqual(interfaces["p-br0-ovs"]["type"], "veth") + self.assertEqual( + interfaces["p-br0-ovs"]["veth"], + {"peer": "p-br0-phy"} + ) + self.assertEqual(interfaces["p-br0-ovs"]["ipv4"], {"enabled": False}) + self.assertEqual(interfaces["p-br0-ovs"]["ipv6"], {"enabled": False}) + + bridge_ports = interfaces["br0"]["bridge"]["port"] + self.assertIn({"name": "eth1"}, bridge_ports) + self.assertIn({"name": "p-br0-phy"}, bridge_ports) + + def test_route_without_table(self): + """Test route without table-id omits the field.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + {"cidr": "10.0.0.0/24", "gateway": "192.168.1.1"} + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + routes = result["routes"]["config"] + self.assertEqual(len(routes), 1) + self.assertEqual(routes[0]["destination"], "10.0.0.0/24") + self.assertEqual(routes[0]["next-hop-address"], "192.168.1.1") + self.assertNotIn("table-id", routes[0]) + + def test_route_with_supported_attributes(self): + """Test route maps supported nmstate attributes.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + { + "cidr": "10.0.0.0/24", + "gateway": "192.168.1.1", + "metric": "400", + "onlink": "true", + "src": "192.168.1.2", + } + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + routes = result["routes"]["config"] + self.assertEqual(len(routes), 1) + self.assertEqual(routes[0]["metric"], 400) + self.assertTrue(routes[0]["on-link"]) + self.assertEqual(routes[0]["source"], "192.168.1.2") + + def test_route_with_supported_options(self): + """Test documented route options map to nmstate attributes.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + { + "cidr": "10.0.0.0/24", + "gateway": "192.168.1.1", + "options": [ + "onlink", + "metric 400", + "src 192.168.1.2", + ], + } + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + routes = result["routes"]["config"] + self.assertEqual(len(routes), 1) + self.assertEqual(routes[0]["metric"], 400) + self.assertTrue(routes[0]["on-link"]) + self.assertEqual(routes[0]["source"], "192.168.1.2") + + def test_route_with_table_name_lookup(self): + """Test route with table name looks up ID from network_route_tables.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + {"cidr": "10.0.0.0/24", "gateway": "192.168.1.1", + "table": "custom-table"} + ], + "network_route_tables": [ + {"name": "custom-table", "id": 100} + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + routes = result["routes"]["config"] + self.assertEqual(len(routes), 1) + self.assertEqual(routes[0]["table-id"], 100) + + def test_route_with_undefined_table_name(self): + """Test route with undefined table name raises ValueError.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + {"cidr": "10.0.0.0/24", "gateway": "192.168.1.1", + "table": "undefined-table"} + ], + "network_route_tables": [], + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn("undefined-table", str(cm.exception)) + self.assertIn("not defined in network_route_tables", str(cm.exception)) + + def test_route_with_table_id(self): + """Test route with numeric table ID includes table-id.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + {"cidr": "10.0.0.0/24", "gateway": "192.168.1.1", + "table": 100} + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + routes = result["routes"]["config"] + self.assertEqual(len(routes), 1) + self.assertEqual(routes[0]["table-id"], 100) + + def test_route_with_string_numeric_table_id(self): + """Test route with string numeric table ID converts to int.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + {"cidr": "10.0.0.0/24", "gateway": "192.168.1.1", + "table": "100"} + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + routes = result["routes"]["config"] + self.assertEqual(len(routes), 1) + self.assertEqual(routes[0]["table-id"], 100) + + def test_route_string_not_supported(self): + """Test string-format routing route raises ValueError.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + "10.0.0.0/24 via 192.168.1.1" + ], + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn( + "Network 'test' has invalid routing route format at index 0", + str(cm.exception) + ) + self.assertIn( + "String format routes are only supported by the default network " + "engine", + str(cm.exception) + ) + + def test_route_unsupported_option_not_supported(self): + """Test unsupported route options raise ValueError.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + { + "cidr": "10.0.0.0/24", + "gateway": "192.168.1.1", + "options": ["mtu 1400"], + } + ], + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn( + "unsupported routing route option", + str(cm.exception) + ) + + def test_route_conflicting_option_not_supported(self): + """Test conflicting route keys and options raise ValueError.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + { + "cidr": "10.0.0.0/24", + "gateway": "192.168.1.1", + "metric": 100, + "options": ["metric 400"], + } + ], + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn( + "conflicting routing route option", + str(cm.exception) + ) + + def test_rule_string_not_supported(self): + """Test string-format routing rule raises ValueError.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_rules": [ + "from 192.168.1.0/24 table 200" + ], + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn("Network 'test' has invalid routing rule format", + str(cm.exception)) + self.assertIn("String format rules are only supported by the default " + "network engine", str(cm.exception)) + + def test_rule_minimal(self): + """Test rule with minimal fields omits optional ones.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_rules": [ + {"to": "10.0.0.0/24", "table": "custom-table"} + ], + "network_route_tables": [ + {"name": "custom-table", "id": 200} + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + rules = result["route-rules"]["config"] + self.assertEqual(len(rules), 1) + self.assertEqual(rules[0]["ip-to"], "10.0.0.0/24") + self.assertEqual(rules[0]["route-table"], 200) + self.assertNotIn("ip-from", rules[0]) + self.assertNotIn("priority", rules[0]) + + def test_rule_complete(self): + """Test rule with all fields includes them.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_rules": [ + {"from": "192.168.1.0/24", "to": "10.0.0.0/24", + "priority": 100, "table": "custom-table"} + ], + "network_route_tables": [ + {"name": "custom-table", "id": 200} + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + rules = result["route-rules"]["config"] + self.assertEqual(len(rules), 1) + self.assertEqual(rules[0]["ip-from"], "192.168.1.0/24") + self.assertEqual(rules[0]["ip-to"], "10.0.0.0/24") + self.assertEqual(rules[0]["priority"], 100) + self.assertEqual(rules[0]["route-table"], 200) + + def test_rule_with_undefined_table_name(self): + """Test rule with undefined table name raises ValueError.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_rules": [ + {"to": "10.0.0.0/24", "table": "undefined-table"} + ], + "network_route_tables": [], + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn("undefined-table", str(cm.exception)) + self.assertIn("not defined in network_route_tables", str(cm.exception)) + + def test_bond_without_mode(self): + """Test bond without mode gets default balance-rr mode.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "bond0", + "test_bond_slaves": ["eth0", "eth1"], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + bond_iface = next(i for i in result["interfaces"] + if i["name"] == "bond0") + self.assertEqual(bond_iface["type"], "bond") + # nmstate requires bond mode, so default is provided + self.assertEqual(bond_iface["link-aggregation"]["mode"], "balance-rr") + self.assertEqual(set(bond_iface["link-aggregation"]["port"]), + {"eth0", "eth1"}) + + def test_bond_with_explicit_mode(self): + """Test bond with explicit mode uses specified mode.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "bond0", + "test_bond_slaves": ["eth0", "eth1"], + "test_bond_mode": "802.3ad", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + bond_iface = next(i for i in result["interfaces"] + if i["name"] == "bond0") + self.assertEqual(bond_iface["type"], "bond") + self.assertEqual(bond_iface["link-aggregation"]["mode"], "802.3ad") + self.assertEqual(set(bond_iface["link-aggregation"]["port"]), + {"eth0", "eth1"}) diff --git a/kayobe/tests/unit/test_nmstate_apply.py b/kayobe/tests/unit/test_nmstate_apply.py new file mode 100644 index 000000000..0573ea18e --- /dev/null +++ b/kayobe/tests/unit/test_nmstate_apply.py @@ -0,0 +1,133 @@ +# Copyright (c) 2026 StackHPC Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import importlib.util +from pathlib import Path +import unittest +from unittest import mock + + +MODULE_PATH = ( + Path(__file__).resolve().parents[3] / + "ansible/roles/network-nmstate/library/nmstate_apply.py" +) + + +class ModuleFailed(Exception): + def __init__(self, payload): + super().__init__(payload.get("msg", "module failed")) + self.payload = payload + + +class ModuleExited(Exception): + def __init__(self, payload): + super().__init__("module exited") + self.payload = payload + + +class FakeModule: + def __init__(self, params): + self.params = params + + def fail_json(self, **kwargs): + raise ModuleFailed(kwargs) + + def exit_json(self, **kwargs): + raise ModuleExited(kwargs) + + +class TestNMStateApply(unittest.TestCase): + + def _load_module(self): + spec = importlib.util.spec_from_file_location( + "kayobe_nmstate_apply_module", + MODULE_PATH, + ) + if spec is None or spec.loader is None: + raise RuntimeError("Failed to load nmstate_apply module spec") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def test_import_failure(self): + module = self._load_module() + fake_module = FakeModule({"state": {}, "debug": False}) + + with mock.patch.object( + module, "AnsibleModule", return_value=fake_module + ): + with mock.patch.object( + module.importlib, + "import_module", + side_effect=ImportError("No module named libnmstate"), + ): + with self.assertRaises(ModuleFailed) as context: + module.run_module() + + message = context.exception.payload["msg"] + self.assertIn("Failed to import libnmstate module", message) + self.assertIn("python3-libnmstate", message) + + def test_apply_failure(self): + module = self._load_module() + fake_module = FakeModule({"state": {"interfaces": []}, "debug": False}) + + fake_libnmstate = mock.Mock() + fake_libnmstate.show.return_value = {"interfaces": []} + fake_libnmstate.apply.side_effect = RuntimeError("apply failed") + + with mock.patch.object( + module, "AnsibleModule", return_value=fake_module + ): + with mock.patch.object( + module.importlib, + "import_module", + return_value=fake_libnmstate, + ): + with self.assertRaises(ModuleFailed) as context: + module.run_module() + + self.assertIn( + "Failed to apply nmstate state", + context.exception.payload["msg"], + ) + + def test_apply_success_debug_output(self): + module = self._load_module() + desired_state = {"interfaces": [{"name": "eth0", "state": "up"}]} + fake_module = FakeModule({"state": desired_state, "debug": True}) + + previous_state = {"interfaces": [{"name": "eth0", "state": "down"}]} + current_state = {"interfaces": [{"name": "eth0", "state": "up"}]} + + fake_libnmstate = mock.Mock() + fake_libnmstate.show.side_effect = [previous_state, current_state] + + with mock.patch.object( + module, "AnsibleModule", return_value=fake_module + ): + with mock.patch.object( + module.importlib, + "import_module", + return_value=fake_libnmstate, + ): + with self.assertRaises(ModuleExited) as context: + module.run_module() + + payload = context.exception.payload + self.assertTrue(payload["changed"]) + self.assertEqual(payload["state"], current_state) + self.assertEqual(payload["previous_state"], previous_state) + self.assertEqual(payload["desired_state"], desired_state) + fake_libnmstate.apply.assert_called_once_with(desired_state) diff --git a/playbooks/kayobe-overcloud-base/overrides.yml.j2 b/playbooks/kayobe-overcloud-base/overrides.yml.j2 index 16f94d9c2..c08a83e5b 100644 --- a/playbooks/kayobe-overcloud-base/overrides.yml.j2 +++ b/playbooks/kayobe-overcloud-base/overrides.yml.j2 @@ -1,4 +1,10 @@ --- +{% set overcloud_network_engine = ci_network_engine | default('default') %} + +{% if overcloud_network_engine != 'default' %} +network_engine: {{ overcloud_network_engine }} +{% endif %} + docker_daemon_debug: true # Use the OpenStack infra's Dockerhub mirror. docker_registry_mirrors: diff --git a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 index 06933a1d0..b0e4cd21d 100644 --- a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 +++ b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 @@ -1,6 +1,7 @@ --- # The following configuration aims to test some of the 'host configure' # command. +{% set host_configure_network_engine = ci_network_engine | default('default') %} # Additional users. controller_users: @@ -10,6 +11,12 @@ controller_users: groups: - stack +# Exercise nmstate networking engine in CI host configure jobs where +# required packages are available. +{% if host_configure_network_engine == "nmstate" %} +network_engine: {{ host_configure_network_engine }} +{% endif %} + # Additional network interfaces, testing a variety of interface configurations. controller_extra_network_interfaces: - test_net_eth @@ -28,7 +35,7 @@ network_route_tables: - id: 2 name: kayobe-test-route-table -# dummy2: Ethernet interface. +# dummy2: Dummy interface for testing. test_net_eth_cidr: 192.168.34.0/24 test_net_eth_routes: - cidr: 192.168.40.0/24 @@ -44,7 +51,12 @@ test_net_eth_vlan_routes: gateway: 192.168.35.254 table: kayobe-test-route-table test_net_eth_vlan_rules: -{% if ansible_facts.os_family == 'RedHat' %} +{% if ansible_facts.os_family == 'RedHat' and host_configure_network_engine == 'nmstate' %} + - from: 192.168.35.0/24 + table: kayobe-test-route-table + - to: 192.168.35.0/24 + table: kayobe-test-route-table +{% elif ansible_facts.os_family == 'RedHat' %} - from 192.168.35.0/24 table 2 - to: 192.168.35.0/24 table: kayobe-test-route-table @@ -73,6 +85,7 @@ test_net_bridge_vlan_zone: test-zone3 test_net_bond_cidr: 192.168.38.0/24 test_net_bond_interface: bond0 test_net_bond_bond_slaves: [dummy5, dummy6] +test_net_bond_bond_mode: balance-rr test_net_bond_zone: test-zone3 # bond0.44: VLAN subinterface of bond0. diff --git a/releasenotes/notes/nmstate-networking-engine-9eca23fe61902134.yaml b/releasenotes/notes/nmstate-networking-engine-9eca23fe61902134.yaml new file mode 100644 index 000000000..b6ec7a908 --- /dev/null +++ b/releasenotes/notes/nmstate-networking-engine-9eca23fe61902134.yaml @@ -0,0 +1,49 @@ +--- +features: + - | + Adds an opt-in ``nmstate`` network engine + (``network_engine: nmstate``) for host network configuration via + NetworkManager/libnmstate. + - | + Supports Ethernet, VLAN, bond, bridge, routes, and routing rules, + and adds OVS patch-link veth generation for overcloud bridge-to-OVS + connectivity. + - | + Adds structured ethtool configuration via + ``_ethtool_config`` for ring parameters and selected + offload features. + - | + The nmstate network engine is only supported on Rocky Linux. Ubuntu Noble + is not supported because the required system packages (nmstate, + python3-libnmstate) are not available in Ubuntu repositories. Attempting + to use nmstate on Ubuntu will fail with a clear error message directing + users to use the ``legacy`` network engine. + +upgrade: + - | + Introduces ``network_engine`` in ``globals.yml``: + ``legacy`` (default) and ``nmstate``. + - | + With ``nmstate``, ethtool settings use structured YAML in + ``_ethtool_config``. ``legacy`` engine behavior is + unchanged. + - | + With ``network_engine: nmstate``, ``_rules`` entries must use + dict format (keys such as ``from``, ``to``, ``priority``, ``table``). + String-format rules are rejected on the ``nmstate`` path. ``legacy`` + engine behavior is unchanged. + - | + Switching to ``nmstate`` may reconfigure host networking and cause + temporary connectivity disruption. + +fixes: + - | + Fixes host configure regressions when using ``network_engine: nmstate`` on + Rocky Linux. Kayobe now ensures named route tables from + ``network_route_tables`` are defined in ``/etc/iproute2/rt_tables`` for + nmstate-managed hosts. These fixes apply only to the ``nmstate`` engine + path; ``legacy`` engine behavior is unchanged. + - | + When using ``network_engine: nmstate`` with firewalld enabled, Kayobe now + reconciles interface-to-zone mappings in both permanent and runtime + firewalld configuration for interfaces that define ``_zone``. diff --git a/zuul.d/jobs.yaml b/zuul.d/jobs.yaml index 08a4328ee..042114e69 100644 --- a/zuul.d/jobs.yaml +++ b/zuul.d/jobs.yaml @@ -108,6 +108,7 @@ previous_release: "{{ '2025.1' if is_slurp else '2025.2' }}" tls_enabled: false container_engine: 'docker' + ci_network_engine: default ironic_boot_mode: "bios" - job: @@ -140,6 +141,7 @@ nodeset: kayobe-rocky10-16GB vars: container_engine: podman + ci_network_engine: nmstate - job: name: kayobe-overcloud-ubuntu-noble @@ -315,6 +317,7 @@ nodeset: kayobe-rocky10 vars: fail2ban_enabled: true + ci_network_engine: nmstate - job: name: kayobe-overcloud-host-configure-ubuntu-noble