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 c5b99ad0c..5723e9407 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/kolla-ansible/tasks/install.yml b/ansible/roles/kolla-ansible/tasks/install.yml index 2e0d4c8c4..a3bc742a2 100644 --- a/ansible/roles/kolla-ansible/tasks/install.yml +++ b/ansible/roles/kolla-ansible/tasks/install.yml @@ -147,12 +147,25 @@ virtualenv: "{{ kolla_ansible_venv }}" virtualenv_python: "{{ kolla_ansible_venv_python }}" +- name: Ensure core Ansible collections are installed + command: + cmd: >- + ansible-galaxy collection install --force + -r {{ kolla_ansible_core_requirements_yml }} + -p {{ kolla_ansible_venv }}/share/kolla-ansible/ansible/collections/ + environment: + # NOTE(wszumski): Ignore collections shipped with ansible, so that we can install + # newer versions. + ANSIBLE_COLLECTIONS_SCAN_SYS_PATH: "False" + # NOTE(wszumski): Don't use path configured for kayobe + ANSIBLE_COLLECTIONS_PATH: '' + when: not kolla_ansible_venv_ansible + - name: Ensure Ansible collections are installed command: cmd: >- ansible-galaxy collection install --force -r {{ kolla_ansible_requirements_yml }} - {% if not kolla_ansible_venv_ansible %}-r {{ kolla_ansible_core_requirements_yml }}{% endif %} -p {{ kolla_ansible_venv }}/share/kolla-ansible/ansible/collections/ environment: # NOTE(wszumski): Ignore collections shipped with ansible, so that we can install 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 595d1e110..52403e3e0 100644 --- a/doc/source/configuration/reference/network.rst +++ b/doc/source/configuration/reference/network.rst @@ -69,14 +69,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 @@ -89,6 +89,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 ------------------------ @@ -273,9 +427,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 """"""""""""""""" @@ -297,8 +454,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 @@ -403,7 +560,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), @@ -430,6 +603,26 @@ The following attributes are supported: bond and bridge interfaces, settings apply to underlying interfaces. This should be a string of arguments passed to the ``ethtool`` utility, for example ``"-G ${DEVICE} rx 8192 tx 8192"``. +``ingress_qos_map`` + .. note:: + + ``ingress_qos_map`` is only supported with + ``network_engine: nmstate`` on VLAN interfaces. + + VLAN ingress QoS map configuration. This maps VLAN header Priority Code + Point (PCP) to Linux internal packet priority for incoming packets. + + - Structured list: ``[{from: 7, to: 254}, {from: 3, to: 12}]`` +``egress_qos_map`` + .. note:: + + ``egress_qos_map`` is only supported with + ``network_engine: nmstate`` on VLAN interfaces. + + VLAN egress QoS map configuration. This maps Linux internal packet + priority to VLAN header Priority Code Point (PCP) for outgoing packets. + + - Structured list: ``[{from: 129, to: 7}, {from: 130, to: 6}]`` ``zone`` .. note:: ``zone`` is not currently supported on Ubuntu. @@ -579,6 +772,29 @@ this case, a ``parent`` attribute must specify the underlying interface: Ethernet interfaces, bridges, and bond master interfaces may all be parents to a VLAN interface. +VLAN QoS Mapping (nmstate) +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When using ``network_engine: nmstate``, VLAN interfaces can define native +nmstate QoS maps via ``ingress_qos_map`` and ``egress_qos_map``. These map +between Linux internal packet priority and VLAN header Priority Code Point +(PCP). Use the structured list-of-maps form. + +.. code-block:: yaml + :caption: ``inventory/group_vars//network-interfaces`` + + example_interface: "eth2.{{ example_vlan }}" + example_ingress_qos_map: + - from: 7 + to: 254 + example_egress_qos_map: + - from: 129 + to: 7 + +Undefined QoS map values do not render an nmstate QoS map key. Use an +explicit empty list (``[]``) to render an empty map through nmstate, for +example when clearing an existing map. + Bridges and VLANs ^^^^^^^^^^^^^^^^^ diff --git a/etc/kayobe/globals.yml b/etc/kayobe/globals.yml index bb21aca71..e8705bfaa 100644 --- a/etc/kayobe/globals.yml +++ b/etc/kayobe/globals.yml @@ -74,6 +74,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/ansible.py b/kayobe/ansible.py index 882e2385f..79a86aaf7 100644 --- a/kayobe/ansible.py +++ b/kayobe/ansible.py @@ -303,6 +303,19 @@ def _get_environment(parsed_args, external_playbook=False): env.setdefault("ANSIBLE_TEST_PLUGINS", ":".join(test_plugins)) + if external_playbook: + lookup_plugins = [ + os.path.join(parsed_args.config_path, "ansible", "lookup_plugins"), + utils.get_data_files_path("ansible", "lookup_plugins"), + ] + else: + lookup_plugins = [ + utils.get_data_files_path("ansible", "lookup_plugins"), + os.path.join(parsed_args.config_path, "ansible", "lookup_plugins"), + ] + + env.setdefault("ANSIBLE_LOOKUP_PLUGINS", ":".join(lookup_plugins)) + return env diff --git a/kayobe/plugins/filter/networkd.py b/kayobe/plugins/filter/networkd.py index aafe4ded9..b0bfd9e35 100644 --- a/kayobe/plugins/filter/networkd.py +++ b/kayobe/plugins/filter/networkd.py @@ -303,7 +303,7 @@ def _network(context, name, inventory_hostname, bridge, bond, vlan_interfaces): { 'Link': [ {'MTUBytes': mtu}, - ] + ] + ([{'RequiredForOnline': "false"}] if not ip else []) }, ] diff --git a/kayobe/plugins/filter/nmstate.py b/kayobe/plugins/filter/nmstate.py new file mode 100644 index 000000000..7fae395f1 --- /dev/null +++ b/kayobe/plugins/filter/nmstate.py @@ -0,0 +1,713 @@ +# 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' +} + +MAX_U32 = 2**32 - 1 +MAX_VLAN_PRIORITY = 7 + + +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 + + +def _validate_vlan_qos_map_int(value, network_name, direction, field): + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError( + f"Network '{network_name}' has invalid {direction} QoS map " + f"'{field}' value '{value}'. Expected an integer.") + + if value < 0 or value > MAX_U32: + raise ValueError( + f"Network '{network_name}' has invalid {direction} QoS map " + f"'{field}' value '{value}'. Expected a value in range " + f"0..{MAX_U32}.") + + if direction == "ingress" and field == "from" \ + and value > MAX_VLAN_PRIORITY: + raise ValueError( + f"Network '{network_name}' has invalid ingress QoS map " + f"'from' value '{value}'. Maximum VLAN priority is " + f"{MAX_VLAN_PRIORITY}.") + + if direction == "egress" and field == "to" \ + and value > MAX_VLAN_PRIORITY: + raise ValueError( + f"Network '{network_name}' has invalid egress QoS map " + f"'to' value '{value}'. Maximum VLAN priority is " + f"{MAX_VLAN_PRIORITY}.") + + return value + + +def _validate_vlan_qos_map_entry(entry, network_name, direction): + if not isinstance(entry, dict): + raise ValueError( + f"Network '{network_name}' has invalid {direction} QoS map " + "entry format. Expected a dict with keys 'from' and 'to'.") + + if "from" not in entry or "to" not in entry: + raise ValueError( + f"Network '{network_name}' has invalid {direction} QoS map " + "entry. Required keys are 'from' and 'to'.") + + return { + "from": _validate_vlan_qos_map_int( + entry["from"], network_name, direction, "from"), + "to": _validate_vlan_qos_map_int( + entry["to"], network_name, direction, "to") + } + + +def _validate_and_sort_vlan_qos_map(raw_map, network_name, direction): + if raw_map is None: + return None + + if not isinstance(raw_map, list): + raise ValueError( + f"Network '{network_name}' has invalid {direction} QoS map " + f"format '{type(raw_map).__name__}'. Expected a list of dicts " + "with keys 'from' and 'to'.") + + normalized = [] + for entry in raw_map: + normalized.append(_validate_vlan_qos_map_entry( + entry, network_name, direction)) + + normalized.sort(key=lambda item: (item["from"], item["to"])) + return normalized + + +def _get_vlan_qos_map(context, name, inventory_hostname, direction): + qos_map = networks.net_attr( + context, name, f"{direction}_qos_map", inventory_hostname) + return _validate_and_sort_vlan_qos_map(qos_map, name, direction) + + +@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) + } + + ingress_qos_map = _get_vlan_qos_map( + context, name, inventory_hostname, "ingress") + if ingress_qos_map is not None: + iface["vlan"]["ingress-qos-map"] = ingress_qos_map + + egress_qos_map = _get_vlan_qos_map( + context, name, inventory_hostname, "egress") + if egress_qos_map is not None: + iface["vlan"]["egress-qos-map"] = egress_qos_map + + # 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_networkd.py b/kayobe/tests/unit/plugins/filter/test_networkd.py index ffcedecca..c1d42bd09 100644 --- a/kayobe/tests/unit/plugins/filter/test_networkd.py +++ b/kayobe/tests/unit/plugins/filter/test_networkd.py @@ -508,8 +508,9 @@ def test_vlan(self): { "Match": [ {"Name": "eth0.2"} - ] + ], }, + {'Link': [{'RequiredForOnline': 'false'}]}, ] } self.assertEqual(expected, nets) @@ -541,6 +542,7 @@ def test_vlan_multiple(self): {"Name": "eth0.2"} ] }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], "50-kayobe-eth0.3": [ { @@ -548,6 +550,7 @@ def test_vlan_multiple(self): {"Name": "eth0.3"} ] }, + {'Link': [{'RequiredForOnline': 'false'}]}, ] } self.assertEqual(expected, nets) @@ -577,6 +580,7 @@ def test_vlan_with_parent(self): {"Name": "eth0.2"} ] }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], "50-kayobe-vlan.5": [ { @@ -584,6 +588,7 @@ def test_vlan_with_parent(self): {"Name": "vlan.5"} ] }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], "50-kayobe-vlan6": [ { @@ -591,6 +596,7 @@ def test_vlan_with_parent(self): {"Name": "vlan6"} ] }, + {'Link': [{'RequiredForOnline': 'false'}]}, ] } self.assertEqual(expected, nets) @@ -609,6 +615,7 @@ def test_bridge(self): {"Name": "br0"} ] }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], "50-kayobe-eth0": [ { @@ -652,6 +659,7 @@ def test_bridge_with_bridge_port_net(self): {"Name": "br0"} ] }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], "50-kayobe-eth0": [ { @@ -667,6 +675,7 @@ def test_bridge_with_bridge_port_net(self): { "Link": [ {"MTUBytes": 1400}, + {'RequiredForOnline': 'false'}, ] }, ], @@ -698,6 +707,7 @@ def test_bridge_with_bridge_port_vlan(self): {"Name": "br0"} ] }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], "50-kayobe-eth0": [ { @@ -730,6 +740,7 @@ def test_bridge_with_bridge_port_vlan(self): {"Name": "eth1.2"} ] }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], } self.assertEqual(expected, nets) @@ -747,8 +758,9 @@ def test_bridge_with_bridge_port_vlan_net(self): { "Match": [ {"Name": "br0"} - ] + ], }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], "50-kayobe-eth0": [ { @@ -762,19 +774,21 @@ def test_bridge_with_bridge_port_vlan_net(self): {"VLAN": "eth0.2"} ] }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], "50-kayobe-eth0.2": [ { "Match": [ {"Name": "eth0.2"} - ] + ], }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], "50-kayobe-eth1": [ { "Match": [ {"Name": "eth1"} - ] + ], }, { "Network": [ @@ -799,6 +813,7 @@ def test_bond(self): {"Name": "bond0"} ] }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], "50-kayobe-eth0": [ { @@ -842,6 +857,9 @@ def test_bond_with_bond_member_net(self): {"Name": "bond0"} ] }, + { + "Link": [{'RequiredForOnline': 'false'}] + }, ], "50-kayobe-eth0": [ { @@ -857,6 +875,7 @@ def test_bond_with_bond_member_net(self): { "Link": [ {"MTUBytes": 1400}, + {'RequiredForOnline': 'false'}, ] }, ], @@ -888,6 +907,7 @@ def test_bond_with_bond_member_vlan(self): {"Name": "bond0"} ] }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], "50-kayobe-eth0": [ { @@ -920,6 +940,7 @@ def test_bond_with_bond_member_vlan(self): {"Name": "eth1.2"} ] }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], } self.assertEqual(expected, nets) @@ -937,7 +958,10 @@ def test_bond_with_bond_member_vlan_net(self): { "Match": [ {"Name": "bond0"} - ] + ], + }, + { + "Link": [{'RequiredForOnline': 'false'}] }, ], "50-kayobe-eth0": [ @@ -952,6 +976,9 @@ def test_bond_with_bond_member_vlan_net(self): {"VLAN": "eth0.2"}, ] }, + { + "Link": [{'RequiredForOnline': 'false'}] + }, ], "50-kayobe-eth0.2": [ { @@ -959,6 +986,10 @@ def test_bond_with_bond_member_vlan_net(self): {"Name": "eth0.2"} ] }, + { + "Link": [{'RequiredForOnline': 'false'}] + }, + ], "50-kayobe-eth1": [ { @@ -991,6 +1022,7 @@ def test_veth(self): {"Name": "br0"} ] }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], "50-kayobe-p-br0-phy": [ { @@ -1034,6 +1066,7 @@ def test_veth_with_mtu(self): { "Link": [ {"MTUBytes": 1400}, + {'RequiredForOnline': 'false'}, ] }, ], @@ -1095,14 +1128,16 @@ def test_veth_on_vlan(self): "Network": [ {"VLAN": "br0.42"} ] - } + }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], "50-kayobe-br0.42": [ { "Match": [ {"Name": "br0.42"} - ] + ], }, + {'Link': [{'RequiredForOnline': 'false'}]}, ], "50-kayobe-p-br0-phy": [ { @@ -1180,8 +1215,9 @@ def test_no_veth_on_vlan_without_bridge(self): { "Match": [ {"Name": "eth0.2"} - ] + ], }, + {'Link': [{'RequiredForOnline': 'false'}]}, ] } self.assertEqual(expected, nets) 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..da1fef140 --- /dev/null +++ b/kayobe/tests/unit/plugins/filter/test_nmstate.py @@ -0,0 +1,988 @@ +# 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_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_qos_map_structured(self): + context = self._make_context( + { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "vlan_interface": "eth0.123", + "vlan_ingress_qos_map": [ + {"from": 7, "to": 254}, + {"from": 3, "to": 12}, + ], + "vlan_egress_qos_map": [ + {"from": 130, "to": 6}, + {"from": 129, "to": 7}, + ], + } + ) + + result = nmstate.nmstate_config(context, ["vlan"]) + vlan_iface = next( + iface for iface in result["interfaces"] + if iface["name"] == "eth0.123" + ) + self.assertEqual( + vlan_iface["vlan"]["ingress-qos-map"], + [{"from": 3, "to": 12}, {"from": 7, "to": 254}], + ) + self.assertEqual( + vlan_iface["vlan"]["egress-qos-map"], + [{"from": 129, "to": 7}, {"from": 130, "to": 6}], + ) + + def test_vlan_interface_qos_map_invalid_input(self): + test_cases = [ + ("non-list input", {"vlan_ingress_qos_map": ""}), + ("missing required key", {"vlan_ingress_qos_map": [{"from": 1}]}), + ("wrong entry type", {"vlan_ingress_qos_map": ["1:2"]}), + ( + "invalid numeric bound", + {"vlan_egress_qos_map": [{"from": 129, "to": 8}]}, + ), + ] + + for test_case, qos_map in test_cases: + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "vlan_interface": "eth0.123", + } + variables.update(qos_map) + context = self._make_context(variables) + + with self.subTest(test_case=test_case): + with self.assertRaises(ValueError): + nmstate.nmstate_config(context, ["vlan"]) + + 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_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_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") + self.assertNotIn("table-id", routes[0]) + + 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_ansible.py b/kayobe/tests/unit/test_ansible.py index 458373f9a..143f652e0 100644 --- a/kayobe/tests/unit/test_ansible.py +++ b/kayobe/tests/unit/test_ansible.py @@ -77,6 +77,10 @@ def test_run_playbooks(self, mock_validate, mock_vars, mock_run): "/etc/kayobe/ansible/test_plugins", utils.get_data_files_path("ansible", "test_plugins"), ]), + "ANSIBLE_LOOKUP_PLUGINS": ":".join([ + "/etc/kayobe/ansible/lookup_plugins", + utils.get_data_files_path("ansible", "lookup_plugins"), + ]), } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) @@ -127,6 +131,10 @@ def test_run_playbooks_internal(self, mock_validate, mock_vars, mock_run): utils.get_data_files_path("ansible", "test_plugins"), "/etc/kayobe/ansible/test_plugins", ]), + "ANSIBLE_LOOKUP_PLUGINS": ":".join([ + utils.get_data_files_path("ansible", "lookup_plugins"), + "/etc/kayobe/ansible/lookup_plugins", + ]), } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) @@ -245,6 +253,10 @@ def test_run_playbooks_all_the_args(self, mock_validate, mock_vars, "/path/to/config/ansible/test_plugins", utils.get_data_files_path("ansible", "test_plugins"), ]), + "ANSIBLE_LOOKUP_PLUGINS": ":".join([ + "/path/to/config/ansible/lookup_plugins", + utils.get_data_files_path("ansible", "lookup_plugins"), + ]), } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) @@ -307,6 +319,7 @@ def test_run_playbooks_all_the_long_args(self, mock_ask, mock_validate, "ANSIBLE_ACTION_PLUGINS": mock.ANY, "ANSIBLE_FILTER_PLUGINS": mock.ANY, "ANSIBLE_TEST_PLUGINS": mock.ANY, + "ANSIBLE_LOOKUP_PLUGINS": mock.ANY, } expected_calls = [ mock.call(["which", "kayobe-vault-password-helper"], @@ -348,6 +361,7 @@ def test_run_playbooks_vault_password_file(self, mock_update, "ANSIBLE_ACTION_PLUGINS": mock.ANY, "ANSIBLE_FILTER_PLUGINS": mock.ANY, "ANSIBLE_TEST_PLUGINS": mock.ANY, + "ANSIBLE_LOOKUP_PLUGINS": mock.ANY, } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) @@ -386,6 +400,7 @@ def test_run_playbooks_vault_password_helper(self, mock_validate, "ANSIBLE_ACTION_PLUGINS": mock.ANY, "ANSIBLE_FILTER_PLUGINS": mock.ANY, "ANSIBLE_TEST_PLUGINS": mock.ANY, + "ANSIBLE_LOOKUP_PLUGINS": mock.ANY, } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) @@ -452,6 +467,7 @@ def test_run_playbooks_func_args(self, mock_validate, mock_vars, mock_run): "ANSIBLE_ACTION_PLUGINS": mock.ANY, "ANSIBLE_FILTER_PLUGINS": mock.ANY, "ANSIBLE_TEST_PLUGINS": mock.ANY, + "ANSIBLE_LOOKUP_PLUGINS": mock.ANY, } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) @@ -489,6 +505,7 @@ def test_run_playbooks_ignore_limit(self, mock_validate, mock_vars, "ANSIBLE_ACTION_PLUGINS": mock.ANY, "ANSIBLE_FILTER_PLUGINS": mock.ANY, "ANSIBLE_TEST_PLUGINS": mock.ANY, + "ANSIBLE_LOOKUP_PLUGINS": mock.ANY, } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) @@ -526,6 +543,7 @@ def test_run_playbooks_list_tasks_arg(self, mock_validate, mock_vars, "ANSIBLE_ACTION_PLUGINS": mock.ANY, "ANSIBLE_FILTER_PLUGINS": mock.ANY, "ANSIBLE_TEST_PLUGINS": mock.ANY, + "ANSIBLE_LOOKUP_PLUGINS": mock.ANY, } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) @@ -558,6 +576,7 @@ def test_run_playbooks_ansible_cfg(self, mock_validate, mock_vars, "ANSIBLE_ACTION_PLUGINS": mock.ANY, "ANSIBLE_FILTER_PLUGINS": mock.ANY, "ANSIBLE_TEST_PLUGINS": mock.ANY, + "ANSIBLE_LOOKUP_PLUGINS": mock.ANY, } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) @@ -592,6 +611,7 @@ def test_run_playbooks_ansible_cfg_env(self, mock_validate, mock_vars, "ANSIBLE_ACTION_PLUGINS": mock.ANY, "ANSIBLE_FILTER_PLUGINS": mock.ANY, "ANSIBLE_TEST_PLUGINS": mock.ANY, + "ANSIBLE_LOOKUP_PLUGINS": mock.ANY, } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) @@ -957,6 +977,7 @@ def test_multiple_inventory_args(self, mock_validate, mock_vars, mock_run): "ANSIBLE_ACTION_PLUGINS": mock.ANY, "ANSIBLE_FILTER_PLUGINS": mock.ANY, "ANSIBLE_TEST_PLUGINS": mock.ANY, + "ANSIBLE_LOOKUP_PLUGINS": mock.ANY, } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) @@ -1001,6 +1022,7 @@ def exists_replacement(path): "ANSIBLE_ACTION_PLUGINS": mock.ANY, "ANSIBLE_FILTER_PLUGINS": mock.ANY, "ANSIBLE_TEST_PLUGINS": mock.ANY, + "ANSIBLE_LOOKUP_PLUGINS": mock.ANY, } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) @@ -1043,6 +1065,7 @@ def exists_replacement(path): "ANSIBLE_ACTION_PLUGINS": mock.ANY, "ANSIBLE_FILTER_PLUGINS": mock.ANY, "ANSIBLE_TEST_PLUGINS": mock.ANY, + "ANSIBLE_LOOKUP_PLUGINS": mock.ANY, } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) @@ -1086,6 +1109,7 @@ def exists_replacement(path): "ANSIBLE_ACTION_PLUGINS": mock.ANY, "ANSIBLE_FILTER_PLUGINS": mock.ANY, "ANSIBLE_TEST_PLUGINS": mock.ANY, + "ANSIBLE_LOOKUP_PLUGINS": mock.ANY, } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) @@ -1134,6 +1158,7 @@ def exists_replacement(path): "ANSIBLE_ACTION_PLUGINS": mock.ANY, "ANSIBLE_FILTER_PLUGINS": mock.ANY, "ANSIBLE_TEST_PLUGINS": mock.ANY, + "ANSIBLE_LOOKUP_PLUGINS": mock.ANY, } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) @@ -1214,6 +1239,7 @@ def exists_replacement(path): "ANSIBLE_ACTION_PLUGINS": mock.ANY, "ANSIBLE_FILTER_PLUGINS": mock.ANY, "ANSIBLE_TEST_PLUGINS": mock.ANY, + "ANSIBLE_LOOKUP_PLUGINS": mock.ANY, } mock_run.assert_called_once_with(expected_cmd, check_output=False, quiet=False, env=expected_env) 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-base/pre.yml b/playbooks/kayobe-base/pre.yml index 2f9232e34..82cdd8c1a 100644 --- a/playbooks/kayobe-base/pre.yml +++ b/playbooks/kayobe-base/pre.yml @@ -15,7 +15,7 @@ callbacks_enabled = ansible.posix.profile_tasks # Improve readability of ansible output. - stdout_callback = yaml + callback_result_format = yaml [ssh_connection] # NOTE(wszusmki): Disable pipelining due to: @@ -36,7 +36,7 @@ callbacks_enabled = ansible.posix.profile_tasks # Improve readability of ansible output. - stdout_callback = yaml + callback_result_format = yaml [ssh_connection] # NOTE(wszusmki): Disable pipelining due to: diff --git a/playbooks/kayobe-overcloud-base/overrides.yml.j2 b/playbooks/kayobe-overcloud-base/overrides.yml.j2 index b37ef4333..6dd5a34ad 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 9e9424981..bdc898f45 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 @@ -55,6 +67,18 @@ test_net_eth_vlan_rules: table: kayobe-test-route-table {% endif %} test_net_eth_vlan_zone: test-zone1 +{% if host_configure_network_engine == "nmstate" %} +test_net_eth_vlan_ingress_qos_map: + - from: 3 + to: 12 + - from: 7 + to: 254 +test_net_eth_vlan_egress_qos_map: + - from: 129 + to: 7 + - from: 130 + to: 6 +{% endif %} # br0: bridge with ports dummy3, dummy4. test_net_bridge_cidr: 192.168.36.0/24 @@ -73,6 +97,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/playbooks/kayobe-overcloud-host-configure-base/run.yml b/playbooks/kayobe-overcloud-host-configure-base/run.yml index bca6bc7f4..1863cc753 100644 --- a/playbooks/kayobe-overcloud-host-configure-base/run.yml +++ b/playbooks/kayobe-overcloud-host-configure-base/run.yml @@ -34,6 +34,8 @@ command: "{{ testinfra_venv }}/bin/py.test {{ test_path }} --html={{ logs_dir }}/test-results.html --self-contained-html" environment: SITE_MIRROR_FQDN: "{{ zuul_site_mirror_fqdn }}" + FAIL2BAN_ENABLED: "{{ fail2ban_enabled | default(false) }}" + CI_NETWORK_ENGINE: "{{ ci_network_engine | default('default') }}" - name: Test bouncing interfaces shell: 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 e932e5b5e..e54bc21e3 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 @@ -4,6 +4,7 @@ # Uses py.test and TestInfra. import ipaddress +import json import os import distro @@ -33,6 +34,28 @@ def _is_ubuntu_noble(): return name == 'Ubuntu' and version == '24.04' +def _is_fail2ban_enabled(): + return os.environ.get('FAIL2BAN_ENABLED', 'false').lower() == 'true' + + +def _is_nmstate_enabled(): + return os.environ.get('CI_NETWORK_ENGINE', 'default').lower() == 'nmstate' + + +def _get_vlan_info_data(host, interface_name): + output = host.check_output( + '/sbin/ip -d -j link show dev %s' % interface_name) + link_data = json.loads(output) + assert len(link_data) == 1 + linkinfo = link_data[0].get('linkinfo', {}) + assert linkinfo.get('info_kind') == 'vlan' + return linkinfo.get('info_data', {}) + + +def _normalize_qos_map(qos_map): + return {(entry['from'], entry['to']) for entry in qos_map} + + def test_network_ethernet(host): interface = host.interface('dummy2') assert interface.exists @@ -55,6 +78,15 @@ def test_network_ethernet_vlan(host): expected_to = 'to 192.168.35.0/24 lookup kayobe-test-route-table' assert expected_from in rules assert expected_to in rules + vlan_info_data = _get_vlan_info_data(host, 'dummy2.42') + assert vlan_info_data['id'] == 42 + if _is_nmstate_enabled(): + ingress_qos = _normalize_qos_map(vlan_info_data['ingress_qos']) + egress_qos = _normalize_qos_map(vlan_info_data['egress_qos']) + assert (3, 12) in ingress_qos + assert (7, 254) in ingress_qos + assert (129, 7) in egress_qos + assert (130, 6) in egress_qos def test_network_bridge(host): 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/releasenotes/notes/fixes-lookup-plugins-for-external-playbooks-cc55db9decd7e33b.yaml b/releasenotes/notes/fixes-lookup-plugins-for-external-playbooks-cc55db9decd7e33b.yaml new file mode 100644 index 000000000..cea4d6f13 --- /dev/null +++ b/releasenotes/notes/fixes-lookup-plugins-for-external-playbooks-cc55db9decd7e33b.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + Fixes an issue where internal kayobe lookup plugins could not be used in + external playbooks. + `LP#2142876 `__ diff --git a/releasenotes/notes/fixes-wait-network-online-c7e01e49174ef313.yaml b/releasenotes/notes/fixes-wait-network-online-c7e01e49174ef313.yaml new file mode 100644 index 000000000..f8fadb87b --- /dev/null +++ b/releasenotes/notes/fixes-wait-network-online-c7e01e49174ef313.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + Fixes an issue with systemd-networkd-wait-online.service failing when some + interfaces were not configured with an IP address. This affected systemd + units depending on network-online.target. diff --git a/releasenotes/notes/nmstate-networking-engine-9eca23fe61902134.yaml b/releasenotes/notes/nmstate-networking-engine-9eca23fe61902134.yaml new file mode 100644 index 000000000..c7bede6b5 --- /dev/null +++ b/releasenotes/notes/nmstate-networking-engine-9eca23fe61902134.yaml @@ -0,0 +1,40 @@ +--- +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 ``default`` network engine. + +upgrade: + - | + Introduces ``network_engine`` in ``globals.yml`` to control which + engine is used to configure network interfaces. The options are ``default``, + which uses ``MichaelRigart.interfaces`` on Enterprise Linux and + systemd-networkd on Ubuntu, and ``nmstate``. + - | + With ``nmstate``, ethtool settings use structured YAML in + ``_ethtool_config``. ``default`` 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. ``default`` + engine behavior is unchanged. + - | + Switching to ``nmstate`` may reconfigure host networking and cause + temporary connectivity disruption. + diff --git a/releasenotes/notes/nmstate-vlan-qos-map-7c4f0f7f57128e7a.yaml b/releasenotes/notes/nmstate-vlan-qos-map-7c4f0f7f57128e7a.yaml new file mode 100644 index 000000000..c09326b9f --- /dev/null +++ b/releasenotes/notes/nmstate-vlan-qos-map-7c4f0f7f57128e7a.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Adds native nmstate support for structured VLAN QoS mapping in Kayobe via + ``_ingress_qos_map`` and ``_egress_qos_map``. + These are rendered to nmstate VLAN keys + ``ingress-qos-map`` and ``egress-qos-map``. diff --git a/requirements.yml b/requirements.yml index 775abdeed..b32a223d1 100644 --- a/requirements.yml +++ b/requirements.yml @@ -16,7 +16,7 @@ collections: - name: stackhpc.network version: 1.0.0 - name: stackhpc.openstack - version: 0.2.2 + version: 0.10.1 roles: - src: ahuffman.resolv diff --git a/zuul.d/jobs.yaml b/zuul.d/jobs.yaml index b1ece48f7..c5e94ffcb 100644 --- a/zuul.d/jobs.yaml +++ b/zuul.d/jobs.yaml @@ -108,6 +108,7 @@ previous_release: "{{ '2024.1' if is_slurp else '2024.2' }}" tls_enabled: false container_engine: 'docker' + ci_network_engine: default ironic_boot_mode: "bios" roles: - zuul: openstack/kolla @@ -162,6 +163,7 @@ nodeset: kayobe-rocky10 vars: container_engine: podman + ci_network_engine: nmstate - job: name: kayobe-overcloud-ubuntu-noble @@ -400,6 +402,9 @@ name: kayobe-overcloud-host-configure-rocky10 parent: kayobe-overcloud-host-configure-base nodeset: kayobe-rocky10 + vars: + fail2ban_enabled: true + ci_network_engine: nmstate - job: name: kayobe-overcloud-host-configure-ubuntu-noble