diff --git a/ansible/filter_plugins/nmstate.py b/ansible/filter_plugins/nmstate.py new file mode 100644 index 000000000..b24e3311b --- /dev/null +++ b/ansible/filter_plugins/nmstate.py @@ -0,0 +1,22 @@ +# Copyright (c) 2026 StackHPC Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from kayobe.plugins.filter import nmstate + + +class FilterModule(object): + """nmstate filters.""" + + def filters(self): + return nmstate.get_filters() diff --git a/ansible/network.yml b/ansible/network.yml index e584b7fd8..8dbc8e0ad 100644 --- a/ansible/network.yml +++ b/ansible/network.yml @@ -43,4 +43,4 @@ - name: Configure the network include_role: - name: "network-{{ ansible_facts.os_family | lower }}" + name: "{{ 'network-nmstate' if network_engine | default('default') == 'nmstate' else 'network-' + ansible_facts.os_family | lower }}" diff --git a/ansible/roles/network-nmstate/defaults/main.yml b/ansible/roles/network-nmstate/defaults/main.yml new file mode 100644 index 000000000..bc5b73765 --- /dev/null +++ b/ansible/roles/network-nmstate/defaults/main.yml @@ -0,0 +1,2 @@ +--- +network_nmstate_install_packages: true diff --git a/ansible/roles/network-nmstate/library/nmstate_apply.py b/ansible/roles/network-nmstate/library/nmstate_apply.py new file mode 100644 index 000000000..f04b89399 --- /dev/null +++ b/ansible/roles/network-nmstate/library/nmstate_apply.py @@ -0,0 +1,133 @@ +#!/usr/bin/python +# Copyright (c) 2026 StackHPC Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import importlib + +from ansible.module_utils.basic import AnsibleModule + +DOCUMENTATION = """ +--- +module: nmstate_apply +version_added: "19.1" +author: "StackHPC" +short_description: Apply network state using nmstate +description: + - "This module allows applying a network state using nmstate library. + Provides idempotency by comparing desired and current states." +options: + state: + description: + - Network state definition in nmstate format + required: True + type: dict + debug: + description: + - Include previous and desired states in output for debugging + required: False + default: False + type: bool +requirements: + - libnmstate +""" + +EXAMPLES = """ +- name: Apply network state + nmstate_apply: + state: + interfaces: + - name: eth0 + type: ethernet + state: up + ipv4: + address: + - ip: 192.168.1.10 + prefix-length: 24 + dhcp: false + debug: false +""" + +RETURN = """ +changed: + description: Whether the network state was modified + type: bool + returned: always +state: + description: Current network state after applying desired state + type: dict + returned: always +previous_state: + description: Network state before applying (when debug=true) + type: dict + returned: when debug=True +desired_state: + description: Desired network state that was applied (when debug=true) + type: dict + returned: when debug=True +""" + + +def run_module(): + argument_spec = dict( + state=dict(required=True, type="dict"), + debug=dict(default=False, type="bool"), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + ) + + try: + libnmstate = importlib.import_module("libnmstate") + except Exception as e: + module.fail_json( + msg=( + "Failed to import libnmstate module. " + "Ensure nmstate Python dependencies are installed " + "(for example python3-libnmstate). " + "Import errors: %s" + ) % repr(e) + ) + + previous_state = libnmstate.show() + desired_state = module.params["state"] + debug = module.params["debug"] + + result = {"changed": False} + + try: + libnmstate.apply(desired_state) + except Exception as e: + module.fail_json(msg="Failed to apply nmstate state: %s" % repr(e)) + + current_state = libnmstate.show() + + if current_state != previous_state: + result["changed"] = True + if debug: + result["previous_state"] = previous_state + result["desired_state"] = desired_state + + result["state"] = current_state + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == "__main__": + main() diff --git a/ansible/roles/network-nmstate/tasks/main.yml b/ansible/roles/network-nmstate/tasks/main.yml new file mode 100644 index 000000000..4bff2ed9d --- /dev/null +++ b/ansible/roles/network-nmstate/tasks/main.yml @@ -0,0 +1,204 @@ +--- +- name: Validate nmstate is supported on this OS + ansible.builtin.assert: + that: + - ansible_facts.os_family == "RedHat" + fail_msg: >- + The nmstate network engine is not supported on {{ ansible_facts.distribution }} + {{ ansible_facts.distribution_version }}. nmstate requires system packages + (nmstate, python3-libnmstate) which are not available in {{ ansible_facts.distribution }} + repositories. The nmstate engine is only supported on Rocky Linux. + For Ubuntu Noble, use the default network engine (network_engine: default) + which provides robust networking via systemd-networkd. + +- name: Include nmstate role variables + include_vars: RedHat.yml + +- import_role: + name: ahuffman.resolv + when: resolv_is_managed | bool + become: true + +- name: Ensure /etc/iproute2 directory exists + ansible.builtin.file: + path: /etc/iproute2 + state: directory + owner: root + group: root + mode: '0755' + become: true + when: + - (network_route_tables | default([])) | length > 0 + +- name: Ensure IP routing tables are defined + blockinfile: + create: true + dest: /etc/iproute2/rt_tables + owner: root + group: root + mode: '0644' + block: | + {% for table in network_route_tables %} + {{ table.id }} {{ table.name }} + {% endfor %} + become: true + when: + - (network_route_tables | default([])) | length > 0 + +- name: Ensure nmstate packages are installed + package: + name: "{{ network_nmstate_packages }}" + state: present + become: true + when: + - network_nmstate_install_packages | bool + - network_nmstate_packages | length > 0 + +- name: Ensure NetworkManager is enabled and running + service: + name: NetworkManager + state: started + enabled: true + become: true + +- name: Ensure NetworkManager DNS config is present only if required + become: true + community.general.ini_file: + path: /etc/NetworkManager/NetworkManager.conf + section: main + option: "{{ item.option }}" + value: "{{ item.value }}" + state: "{{ 'present' if resolv_is_managed | bool else 'absent' }}" + loop: + - option: dns + value: none + - option: rc-manager + value: unmanaged + when: + - ansible_facts.os_family == "RedHat" and ansible_facts.distribution_major_version | int >= 9 + register: dns_config_task + +- name: Reload NetworkManager with DNS config + become: true + systemd: + name: NetworkManager + state: reloaded + daemon_reload: true + when: dns_config_task is changed + +- name: Generate nmstate desired state + set_fact: + network_nmstate_desired_state: "{{ network_interfaces | nmstate_config }}" + +- name: Apply nmstate configuration using module + nmstate_apply: + state: "{{ network_nmstate_desired_state }}" + register: nmstate_apply_result + become: true + vars: + ansible_python_interpreter: "{{ ansible_facts.python.executable }}" + +- name: Initialise nmstate firewalld interface-zone map + set_fact: + network_nmstate_zone_map: {} + +- name: Build nmstate firewalld interface-zone map + set_fact: + network_nmstate_zone_map: >- + {{ network_nmstate_zone_map + | combine({ (item | net_interface): (item | net_zone) }) }} + loop: "{{ network_interfaces | default([]) }}" + when: + - item | net_zone + - item | net_interface + +- name: Build nmstate firewalld interface-zone items + set_fact: + network_nmstate_zone_items: >- + {{ network_nmstate_zone_map + | dict2items(key_name='interface', value_name='zone') }} + +- block: + - name: Ensure firewalld package is installed for nmstate zone sync + package: + name: firewalld + state: present + + - name: Ensure firewalld service is enabled and running for nmstate zone sync + service: + name: firewalld + state: started + enabled: true + + # TODO(gkoper): replace temporary nmcli profile zone sync with native + # zone handling in the nmstate filter/module path. + - name: Gather NetworkManager connection firewalld zones for nmstate interfaces + command: + argv: + - nmcli + - -g + - connection.zone + - connection + - show + - "{{ item.interface }}" + changed_when: false + loop: "{{ network_nmstate_zone_items }}" + register: network_nmstate_nm_zone_result + + - name: Ensure NetworkManager connection firewalld zones are set for nmstate interfaces + command: + argv: + - nmcli + - connection + - modify + - "{{ item.item.interface }}" + - connection.zone + - "{{ item.item.zone }}" + loop: "{{ network_nmstate_nm_zone_result.results }}" + when: + - (item.stdout | default('') | trim) != item.item.zone + + # Keep permanent firewalld configuration in sync first. Runtime state is + # refreshed separately below from permanent config. + - name: Ensure firewalld zones exist for nmstate interfaces + firewalld: + offline: true + permanent: true + state: present + zone: "{{ item }}" + loop: "{{ network_nmstate_zone_items | map(attribute='zone') | unique | list }}" + register: network_nmstate_zones_result + + - name: Ensure permanent firewalld zones are set for nmstate interfaces + firewalld: + interface: "{{ item.interface }}" + offline: true + permanent: true + state: enabled + zone: "{{ item.zone }}" + loop: "{{ network_nmstate_zone_items }}" + register: network_nmstate_perm_result + + - name: Reload firewalld runtime from permanent config before nmstate zone sync + ansible.builtin.service: + name: firewalld + state: reloaded + changed_when: false + when: + - network_nmstate_zones_result is changed or network_nmstate_perm_result is changed + + # TODO(gkoper): investigate NM profile zone mapping to avoid explicit + # firewalld sync in nmstate path. + - name: Ensure runtime firewalld zones are set for nmstate interfaces + firewalld: + interface: "{{ item.interface }}" + immediate: true + permanent: false + state: enabled + zone: "{{ item.zone }}" + loop: "{{ network_nmstate_zone_items }}" + become: true + when: + - ansible_facts.os_family == "RedHat" + - firewalld_enabled | default(false) | bool + - network_nmstate_zone_items | length > 0 diff --git a/ansible/roles/network-nmstate/vars/RedHat.yml b/ansible/roles/network-nmstate/vars/RedHat.yml new file mode 100644 index 000000000..d5f3312e8 --- /dev/null +++ b/ansible/roles/network-nmstate/vars/RedHat.yml @@ -0,0 +1,4 @@ +--- +network_nmstate_packages: + - nmstate + - python3-libnmstate diff --git a/doc/source/configuration/reference/network.rst b/doc/source/configuration/reference/network.rst index 9e6789006..721ee6aee 100644 --- a/doc/source/configuration/reference/network.rst +++ b/doc/source/configuration/reference/network.rst @@ -71,14 +71,14 @@ supported: ``rules`` List of IP routing rules. - On CentOS or Rocky, each item should be a string describing an ``iproute2`` - IP routing rule. - - On Ubuntu, each item should be a dict containing optional items ``from``, - ``to``, ``priority`` and ``table``. ``from`` is the source address prefix - to match with optional prefix. ``to`` is the destination address prefix to - match with optional prefix. ``priority`` is the priority of the rule. - ``table`` is the routing table ID. + The required format depends on the network engine: + + * ``nmstate`` engine: each rule must be a dict. + * ``default`` engine on CentOS/Rocky: each rule may be a string or dict. + * ``default`` engine on Ubuntu (systemd-networkd): each rule must be a dict. + + Dict rules support optional keys ``from``, ``to``, ``priority``, and + ``table``. ``physical_network`` Name of the physical network on which this network exists. This aligns with the physical network concept in neutron. This may be used to customise the @@ -91,6 +91,160 @@ supported: Whether to allocate an IP address for this network. If set to ``true``, an IP address will not be allocated. +.. _configuration-network-engines: + +Network Configuration Engines +============================== + +Kayobe supports multiple network configuration engines to manage networking on +bare metal hosts. + +Available Engines +----------------- + +``default`` (default) + Uses OS-specific networking tools: + + - RedHat/Rocky Linux: ``MichaelRigart.interfaces`` role with ifcfg files + - Debian/Ubuntu: systemd-networkd with .network files + +``nmstate`` + Uses NetworkManager and nmstate for unified, declarative network + configuration. **Supported on Rocky Linux only.** + +Configuring the Network Engine +------------------------------ + +Set the engine in ``${KAYOBE_CONFIG_PATH}/globals.yml``: + +.. code-block:: yaml + :caption: ``globals.yml`` + + network_engine: nmstate # Valid: "default" (default) or "nmstate" + +.. note:: + + The nmstate engine is only supported on Rocky Linux. + For Ubuntu Noble, use the ``default`` engine (default). + +Nmstate Engine Features +------------------------- + +**Structured ethtool configuration** via ``_ethtool_config``: + +.. code-block:: yaml + :caption: ``networks.yml`` + + tenant_ethtool_config: + ring: + rx: 4096 + tx: 2048 + feature: + rx: true # rx-checksum + gso: false # tx-generic-segmentation + hw-tc-offload: true + +Supported ethtool features: + +- **Ring**: ``rx``, ``tx``, ``rx-max``, ``tx-max``, ``rx-jumbo``, ``rx-mini`` +- **Hardware offloads**: ``rx-checksum`` (alias: ``rx``), ``tx-checksum-ip-generic``, + ``rx-gro`` (alias: ``gro``), ``tx-generic-segmentation`` (alias: ``gso``), + ``rx-lro`` (alias: ``lro``), ``hw-tc-offload`` + +**Breaking change**: nmstate uses structured YAML instead of +``_ethtool_opts`` command strings. Default engine preserves existing +behavior. + +**Multiple IP addresses** on a single physical interface are natively supported. + +Advanced: Configuring Interface Types +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +By default, nmstate automatically determines interface types based on +configuration: bridges are ``linux-bridge``, bonds are ``bond``, VLANs are +``vlan``, and interfaces matching ``dummy.*`` are ``dummy``. Other standalone +interfaces default to ``ethernet``. + +For testing or advanced scenarios, you can explicitly configure interface +types using the following attributes. Explicit type settings override +automatic inference. + +``_type`` + Explicit interface type for the network's main interface. + Default: ``dummy`` for interface names matching ``dummy.*``, otherwise + ``ethernet``. + + Supported values: ``ethernet``, ``dummy``, ``veth``, and other interface types + supported by nmstate. Use ``dummy`` for virtual test interfaces. + + .. code-block:: yaml + :caption: ``inventory/group_vars//network-interfaces`` + + # Example: Configure a dummy interface for testing + test_net_interface: dummy2 + test_net_type: dummy + +``_port_type_`` + Explicit type for a specific bridge port. + Default: ``dummy`` for port names matching ``dummy.*``, otherwise + ``ethernet``. + + Used when a bridge port should be a non-ethernet interface type (e.g., ``dummy``, + ``veth``). The ```` must match a port listed in ``_bridge_ports``. + + .. code-block:: yaml + :caption: ``inventory/group_vars//network-interfaces`` + + # Example: Bridge with dummy ports for testing + admin_interface: br0 + admin_bridge_ports: + - dummy3 + - dummy4 + admin_port_type_dummy3: dummy + admin_port_type_dummy4: dummy + +``_slave_type_`` + Explicit type for a specific bond slave. + Default: ``dummy`` for slave names matching ``dummy.*``, otherwise + ``ethernet``. + + Used when a bond slave should be a non-ethernet interface type (e.g., ``dummy``, + ``veth``). The ```` must match a slave listed in ``_bond_slaves``. + + .. code-block:: yaml + :caption: ``inventory/group_vars//network-interfaces`` + + # Example: Bond with dummy slaves for testing + internal_interface: bond0 + internal_bond_slaves: + - dummy5 + - dummy6 + internal_slave_type_dummy5: dummy + internal_slave_type_dummy6: dummy + +.. note:: + + These options are primarily used for testing environments with virtual interfaces. + Production environments typically use physical ethernet interfaces and do not require + explicit type configuration. + +Prerequisites +------------- + +**Operating System Support:** + +The nmstate engine is **only supported on Rocky Linux**. + +Ubuntu Noble is **not supported** because the required system packages +(nmstate, python3-libnmstate) are not available in Ubuntu repositories. +For Ubuntu Noble, use the ``default`` network engine which provides +robust networking configuration via systemd-networkd. + +**Dependencies:** + +- NetworkManager service running and enabled +- nmstate and python3-libnmstate packages (automatically installed on Rocky Linux) + Configuring an IP Subnet ------------------------ @@ -275,9 +429,12 @@ Configuring IP Routing Policy Rules ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ IP routing policy rules may be configured by setting the ``rules`` attribute -for a network to a list of rules. Two formats are supported for defining rules: -string format and dict format. String format rules are only supported on -CentOS Stream and Rocky Linux systems. +for a network to a list of rules. Two formats are available (dict and string), +but support depends on the network engine: + +* ``nmstate`` engine: dict format only. +* ``default`` engine on CentOS Stream/Rocky Linux: dict and string format. +* ``default`` engine on Ubuntu (systemd-networkd): dict format only. Dict format rules """"""""""""""""" @@ -299,8 +456,8 @@ handle traffic from the subnet ``10.1.0.0/24`` using the routing table These rules will be configured on all hosts to which the network is mapped. -String format rules (CentOS Stream/Rocky Linux only) -"""""""""""""""""""""""""""""""""""""""""""""""""""" +String format rules (default engine on CentOS Stream/Rocky Linux only) +"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" The string format of a rule is the string which would be appended to ``ip rule `` to create or delete the rule. Note that when using NetworkManager @@ -404,7 +561,23 @@ The following attributes are supported: Enable or disable the Spanning Tree Protocol (STP) on this bridge. Should be set to a boolean value. The default is not set on Ubuntu systems. ``bond_mode`` - For bond interfaces, the bond's mode, e.g. 802.3ad. + For bond interfaces, the bond's mode, e.g. ``802.3ad``, ``balance-rr``, + ``active-backup``. + + **nmstate engine**: If not specified, defaults to ``balance-rr`` (round-robin + load balancing). This mode works without special switch configuration and is + suitable for most development/test environments. + + **Production recommendation**: Explicitly configure bond mode based on your + network requirements. Common modes: + + - ``802.3ad``: IEEE 802.3ad LACP (requires switch configuration) + - ``balance-rr``: Round-robin (no switch config needed) + - ``active-backup``: Active-backup failover + - ``balance-xor``: XOR hash-based load balancing + + See `Linux bonding documentation `_ + for complete mode descriptions. ``bond_ad_select`` For bond interfaces, the 802.3ad aggregation selection logic to use. Valid values are ``stable`` (default selection logic if not configured), diff --git a/etc/kayobe/globals.yml b/etc/kayobe/globals.yml index 498b5886b..bcf9aca4e 100644 --- a/etc/kayobe/globals.yml +++ b/etc/kayobe/globals.yml @@ -73,6 +73,13 @@ # user would not normally have permission to create. Default is true. #kayobe_control_host_become: +############################################################################### +# Networking configuration. + +# Network configuration engine. Valid options are "default" and "nmstate". +# Default is "default". +#network_engine: default + ############################################################################### # Dummy variable to allow Ansible to accept this file. workaround_ansible_issue_8743: yes diff --git a/kayobe/plugins/filter/nmstate.py b/kayobe/plugins/filter/nmstate.py new file mode 100644 index 000000000..54e4c8155 --- /dev/null +++ b/kayobe/plugins/filter/nmstate.py @@ -0,0 +1,626 @@ +# Copyright (c) 2026 StackHPC Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import re + +import jinja2 +from kayobe.plugins.filter import networks +from kayobe.plugins.filter import utils + + +def _get_ip_config(context, name, inventory_hostname, defroute=None): + ip = networks.net_ip(context, name, inventory_hostname) + bootproto = networks.net_bootproto(context, name, inventory_hostname) + prefix = networks.net_prefix(context, name, inventory_hostname) + + config = {"enabled": False} + if bootproto == "dhcp": + config = {"enabled": True, "dhcp": True} + if defroute is False: + config["auto-routes"] = False + elif ip: + address = {"ip": ip} + if prefix: + address["prefix-length"] = int(prefix) + config = { + "enabled": True, + "dhcp": False, + "address": [address] + } + + return config + + +# Ethtool feature aliases for user convenience (documented aliases only) +ETHTOOL_FEATURE_ALIASES = { + 'rx': 'rx-checksum', + 'gro': 'rx-gro', + 'gso': 'tx-generic-segmentation', + 'lro': 'rx-lro' +} + +# Tier 1 supported ethtool features (most critical performance features) +TIER1_ETHTOOL_FEATURES = { + 'rx-checksum', # Receive checksum offload + 'tx-checksum-ip-generic', # Transmit checksum offload + 'rx-gro', # Generic Receive Offload + 'tx-generic-segmentation', # Generic Segmentation Offload + 'rx-lro', # Large Receive Offload + 'hw-tc-offload' # Hardware traffic control offload +} + +# Supported ring buffer parameters +SUPPORTED_RING_PARAMS = { + 'rx', 'tx', 'rx-max', 'tx-max', 'rx-jumbo', 'rx-mini' +} + + +def _resolve_ethtool_feature_aliases(features): + """Convert ethtool feature aliases to canonical names. + + Args: + features (dict): Feature configuration with possible aliases + + Returns: + dict: Features with aliases resolved to canonical names + + Raises: + ValueError: If feature contains invalid values + """ + if not isinstance(features, dict): + raise ValueError("Ethtool features must be a dictionary") + + resolved = {} + for key, value in features.items(): + if not isinstance(value, bool): + raise ValueError( + f"Ethtool feature '{key}' must be boolean, " + f"got {type(value).__name__}") + + canonical_name = ETHTOOL_FEATURE_ALIASES.get(key, key) + resolved[canonical_name] = value + + return resolved + + +def _validate_ethtool_features(features): + """Validate ethtool features against Tier 1 supported features. + + Args: + features (dict): Resolved feature configuration + + Returns: + dict: Validated features + + Raises: + ValueError: If unsupported features are specified + """ + unsupported = set(features.keys()) - TIER1_ETHTOOL_FEATURES + if unsupported: + supported_list = ', '.join(sorted(TIER1_ETHTOOL_FEATURES)) + alias_list = ', '.join( + f"{alias} -> {canonical}" + for alias, canonical in ETHTOOL_FEATURE_ALIASES.items()) + raise ValueError( + f"Unsupported ethtool features: {', '.join(sorted(unsupported))}. " + f"Tier 1 supported features: {supported_list}. " + f"Supported aliases: {alias_list}") + + return features + + +def _validate_ethtool_ring(ring_config): + """Validate ethtool ring buffer configuration. + + Args: + ring_config (dict): Ring buffer configuration + + Returns: + dict: Validated ring configuration + + Raises: + ValueError: If invalid ring parameters are specified + """ + if not isinstance(ring_config, dict): + raise ValueError("Ethtool ring configuration must be a dictionary") + + unsupported = set(ring_config.keys()) - SUPPORTED_RING_PARAMS + if unsupported: + supported_list = ', '.join(sorted(SUPPORTED_RING_PARAMS)) + raise ValueError( + f"Unsupported ring parameters: {', '.join(sorted(unsupported))}. " + f"Supported parameters: {supported_list}") + + # Validate values are positive integers + for param, value in ring_config.items(): + if not isinstance(value, int) or value < 0: + raise ValueError( + f"Ring parameter '{param}' must be a non-negative integer, " + f"got {value}") + + return ring_config + + +def _process_ethtool_config(ethtool_config): + """Process structured ethtool configuration into nmstate format. + + Args: + ethtool_config (dict): Structured ethtool configuration + + Returns: + dict: nmstate-compatible ethtool configuration + + Raises: + ValueError: If configuration is invalid + """ + if not isinstance(ethtool_config, dict): + raise ValueError("Ethtool configuration must be a dictionary") + + nmstate_ethtool = {} + + # Process ring buffer configuration + if 'ring' in ethtool_config: + validated_ring = _validate_ethtool_ring(ethtool_config['ring']) + nmstate_ethtool['ring'] = validated_ring + + # Process feature configuration with alias resolution and validation + if 'feature' in ethtool_config: + features = ethtool_config['feature'] + resolved_features = _resolve_ethtool_feature_aliases(features) + validated_features = _validate_ethtool_features(resolved_features) + nmstate_ethtool['feature'] = validated_features + + return nmstate_ethtool + + +def _disable_ip_config(iface): + iface["ipv4"] = {"enabled": False} + iface["ipv6"] = {"enabled": False} + + +def _port_name(port): + if isinstance(port, dict): + return port.get("name") + return port + + +def _default_iface_type(iface_name): + # Match MichaelRigart.interfaces dummy_interface_regex: 'dummy.*'. + if isinstance(iface_name, str) and re.match(r"dummy.*", iface_name): + return "dummy" + return "ethernet" + + +def _parse_route_options(context, name, index, route): + supported_keys = { + "cidr", + "gateway", + "metric", + "onlink", + "options", + "src", + "table", + } + unsupported_keys = sorted(set(route) - supported_keys) + if unsupported_keys: + raise ValueError( + f"Network '{name}' has unsupported routing route keys at " + f"index {index} for the nmstate engine: " + f"{', '.join(unsupported_keys)}") + + route_config = {} + + if route.get("metric") is not None: + route_config["metric"] = int(route["metric"]) + if route.get("src") is not None: + route_config["source"] = route["src"] + if route.get("onlink") is not None: + route_config["on-link"] = utils.call_bool_filter( + context, route["onlink"]) + + options = route.get("options") + if options is None: + return route_config + if not isinstance(options, list): + raise ValueError( + f"Network '{name}' has invalid routing route options format at " + f"index {index} for the nmstate engine. Route options must be " + "a list.") + + for option in options: + if not isinstance(option, str): + raise ValueError( + f"Network '{name}' has invalid routing route option at " + f"index {index} for the nmstate engine. Route options must " + "be strings.") + + option_key = None + option_value = None + if option == "onlink": + option_key = "on-link" + option_value = True + elif option.startswith("metric "): + option_key = "metric" + option_value = int(option.split(None, 1)[1]) + elif option.startswith("src "): + option_key = "source" + option_value = option.split(None, 1)[1] + else: + raise ValueError( + f"Network '{name}' has unsupported routing route option at " + f"index {index} for the nmstate engine: '{option}'") + + existing_value = route_config.get(option_key) + if existing_value is not None and existing_value != option_value: + raise ValueError( + f"Network '{name}' has conflicting routing route option at " + f"index {index} for the nmstate engine: '{option}'") + route_config[option_key] = option_value + + return route_config + + +def _get_bond_options(context, name, inventory_hostname): + bond_option_map = { + "bond_ad_select": "ad_select", + "bond_downdelay": "downdelay", + "bond_lacp_rate": "lacp_rate", + "bond_miimon": "miimon", + "bond_updelay": "updelay", + "bond_xmit_hash_policy": "xmit_hash_policy", + } + + bond_options = {} + for attr, option_name in bond_option_map.items(): + value = networks.net_attr(context, name, attr, inventory_hostname) + if value is not None: + bond_options[option_name] = value + + return bond_options + + +@jinja2.pass_context +def nmstate_config(context, names, inventory_hostname=None): + interfaces = {} + routes = [] + rules = [] + + # Get routing table name to ID mapping + route_tables_list = utils.get_hostvar( + context, "network_route_tables", inventory_hostname) + route_tables = {} + if route_tables_list: + route_tables = {table["name"]: table["id"] + for table in route_tables_list} + + def get_iface(name): + if name not in interfaces: + interfaces[name] = {"name": name, "state": "up"} + return interfaces[name] + + for name in names: + iface_name = networks.net_interface(context, name, inventory_hostname) + if not iface_name: + continue + + iface = get_iface(iface_name) + + mtu = networks.net_mtu(context, name, inventory_hostname) + if mtu: + iface["mtu"] = mtu + + # IP Configuration. nmstate supports multiple addresses, but Kayobe + # usually defines one per network. + defroute = networks.net_defroute( + context, name, inventory_hostname) + if defroute is not None: + defroute = utils.call_bool_filter(context, defroute) + + ipv4_config = _get_ip_config( + context, name, inventory_hostname, defroute) + if ipv4_config.get("enabled"): + if "ipv4" not in iface or not iface["ipv4"].get("enabled"): + iface["ipv4"] = ipv4_config + elif not ipv4_config.get("dhcp"): + addresses = ipv4_config["address"] + iface["ipv4"].setdefault("address", []).extend( + addresses) + + # Gateway - only add if defroute allows it + gateway = networks.net_gateway( + context, name, inventory_hostname) + if gateway: + # Respect defroute: only add default route if defroute + # is None (default) or True + if defroute is None or defroute: + routes.append({ + "destination": "0.0.0.0/0", + "next-hop-address": gateway, + "next-hop-interface": iface_name + }) + + # Routes and Rules + net_routes = networks.net_routes(context, name, inventory_hostname) + for i, route in enumerate(net_routes or []): + if not isinstance(route, dict): + raise ValueError( + f"Network '{name}' has invalid routing route format at " + f"index {i} for the nmstate engine. Routes must use dict " + "format. String format routes are only supported by the " + "default network engine.") + route_config = { + "destination": route["cidr"], + "next-hop-address": route.get("gateway"), + "next-hop-interface": iface_name, + } + route_config.update( + _parse_route_options(context, name, i, route)) + table = route.get("table") + if table is not None: + # Look up table name in mapping, or use value if numeric + table_id = route_tables.get(table, table) + # Ensure table_id is an integer + if isinstance(table_id, str): + if table_id.isdigit(): + table_id = int(table_id) + else: + raise ValueError( + f"Routing table '{table}' is not defined in " + f"network_route_tables and is not a valid " + f"numeric table ID") + route_config["table-id"] = int(table_id) + routes.append(route_config) + + net_rules = networks.net_rules(context, name, inventory_hostname) + for rule in net_rules or []: + if not isinstance(rule, dict): + raise ValueError( + f"Network '{name}' has invalid routing rule format for " + "the nmstate engine. Rules must use dict format " + "(keys: from, to, priority, table). String format rules " + "are only supported by the default network engine.") + rule_config = {} + + if rule.get("from") is not None: + rule_config["ip-from"] = rule["from"] + if rule.get("to") is not None: + rule_config["ip-to"] = rule["to"] + + priority_value = rule.get("priority") + if priority_value is not None: + rule_config["priority"] = int(priority_value) + + table = rule.get("table") + if table is not None: + # Look up table name in mapping, or use value if numeric + table_id = route_tables.get(table, table) + # Ensure table_id is an integer + if isinstance(table_id, str): + if table_id.isdigit(): + table_id = int(table_id) + else: + raise ValueError( + f"Routing table '{table}' is not defined in " + f"network_route_tables and is not a valid " + f"numeric table ID") + rule_config["route-table"] = int(table_id) + + rules.append(rule_config) + + # Specific Interface Types + if networks.net_is_bridge(context, name, inventory_hostname): + iface["type"] = "linux-bridge" + br_ports = networks.net_bridge_ports( + context, name, inventory_hostname) + stp = networks.net_bridge_stp( + context, name, inventory_hostname) + + bridge_config = {} # type: dict[str, object] + bridge_config["port"] = [{"name": p} for p in br_ports or []] + + # Only configure STP when explicitly set + if stp is not None: + stp_enabled = stp == "true" + bridge_config["options"] = { + "stp": {"enabled": stp_enabled}} + iface["bridge"] = bridge_config + else: + iface["bridge"] = bridge_config + + # Ensure ports are initialized if not otherwise defined. + # Check for explicit type configuration via + # _port_type_. + for port in br_ports or []: + port_iface = get_iface(port) + if "type" not in port_iface: + # Check for explicit type configuration + port_type = networks.net_attr( + context, name, f"port_type_{port}", + inventory_hostname) + port_iface["type"] = ( + port_type if port_type else _default_iface_type(port)) + + elif networks.net_is_bond(context, name, inventory_hostname): + iface["type"] = "bond" + slaves = networks.net_bond_slaves( + context, name, inventory_hostname) + mode = networks.net_bond_mode(context, name, inventory_hostname) + link_agg_config = {"port": slaves or []} + if mode is not None: + link_agg_config["mode"] = mode + else: + # nmstate requires bond mode. Provide a sensible default. + # balance-rr (round-robin) works in most environments without + # requiring switch configuration. + link_agg_config["mode"] = "balance-rr" + + bond_options = _get_bond_options( + context, name, inventory_hostname) + if bond_options: + link_agg_config["options"] = bond_options + + iface["link-aggregation"] = link_agg_config + # Ensure slaves are initialized if not otherwise defined. + # Check for explicit type configuration via + # _slave_type_. + for slave in slaves or []: + slave_iface = get_iface(slave) + if "type" not in slave_iface: + # Check for explicit type configuration + slave_type = networks.net_attr( + context, name, f"slave_type_{slave}", + inventory_hostname) + slave_iface["type"] = ( + slave_type + if slave_type else _default_iface_type(slave)) + + elif networks.net_is_vlan_interface( + context, name, inventory_hostname): + iface["type"] = "vlan" + vlan_id = networks.net_vlan( + context, name, inventory_hostname) + parent = networks.net_parent( + context, name, inventory_hostname) + + # Derive VLAN ID from interface name if not explicitly + # set + if vlan_id is None: + vlan_match = re.match( + r"^[a-zA-Z0-9_\-]+\.([1-9][\d]{0,3})$", + iface_name) + if vlan_match: + vlan_id = vlan_match.group(1) + else: + # Skip VLAN config if we can't derive the ID + continue + + # Derive parent interface if not explicitly set + if not parent: + parent = re.sub( + r'\.{}$'.format(vlan_id), '', iface_name) + + iface["vlan"] = { + "base-iface": parent, + "id": int(vlan_id) + } + # Ensure parent is initialized + get_iface(parent) + + else: + if "type" not in iface: + # Check for explicit type configuration via _type + iface_type = networks.net_attr( + context, name, "type", inventory_hostname) + iface["type"] = ( + iface_type + if iface_type else _default_iface_type(iface_name)) + + # Process structured ethtool configuration for advanced tuning + ethtool_config = networks.net_attr( + context, name, "ethtool_config", inventory_hostname) + if ethtool_config: + try: + processed_config = _process_ethtool_config(ethtool_config) + if processed_config: + iface["ethtool"] = processed_config + except ValueError as e: + raise ValueError( + f"Invalid ethtool configuration for network " + f"'{name}': {e}") + + # Configure virtual Ethernet patch links to connect Linux bridges that + # carry provision/cleaning/external networks into OVS. + for veth in networks.get_ovs_veths(context, names, inventory_hostname): + bridge_name = veth["bridge"] + phy_name = veth["name"] + peer_name = veth["peer"] + + bridge_iface = get_iface(bridge_name) + bridge_iface["type"] = "linux-bridge" + bridge_config = bridge_iface.get("bridge") + if not isinstance(bridge_config, dict): + bridge_config = {} + bridge_iface["bridge"] = bridge_config + + bridge_ports = bridge_config.get("port") + if not isinstance(bridge_ports, list): + bridge_ports = [] + bridge_config["port"] = bridge_ports + + normalized_bridge_ports = [] + for port in bridge_ports: + port_name = _port_name(port) + if port_name: + normalized_bridge_ports.append({"name": str(port_name)}) + + bridge_config["port"] = normalized_bridge_ports + bridge_ports = normalized_bridge_ports + + if not any(_port_name(port) == phy_name for port in bridge_ports): + bridge_ports.append({"name": phy_name}) + + phy_iface = get_iface(phy_name) + phy_iface["type"] = "veth" + phy_iface["veth"] = {"peer": peer_name} + if veth.get("mtu"): + phy_iface["mtu"] = veth["mtu"] + _disable_ip_config(phy_iface) + + peer_iface = get_iface(peer_name) + peer_iface["type"] = "veth" + peer_iface["veth"] = {"peer": phy_name} + if veth.get("mtu"): + peer_iface["mtu"] = veth["mtu"] + _disable_ip_config(peer_iface) + + # Filter routes that have next-hop information + valid_routes = [] + for route in routes: + if not isinstance(route, dict): + continue + has_next_hop_address = route.get("next-hop-address") is not None + has_next_hop_interface = route.get("next-hop-interface") is not None + has_next_hop = has_next_hop_address or has_next_hop_interface + if has_next_hop: + valid_routes.append(route) + + # Sort interfaces to ensure dependencies come before dependents. + # Bridges must come after their ports, VLANs after their base interfaces. + def interface_sort_key(iface): + iface_type = iface.get("type", "ethernet") + # Process in order: ethernet, bond, veth, vlan, then bridges + # This ensures ports exist before bridges that use them + type_order = { + "dummy": 0, # Dummy interfaces are base types like ethernet + "ethernet": 0, + "bond": 1, + "veth": 2, + "vlan": 3, + "linux-bridge": 4, + } + return type_order.get(iface_type, 5) + + sorted_interfaces = sorted(interfaces.values(), key=interface_sort_key) + + return { + "interfaces": sorted_interfaces, + "routes": {"config": valid_routes}, + "route-rules": {"config": rules} + } + + +def get_filters(): + return { + "nmstate_config": nmstate_config, + } diff --git a/kayobe/tests/unit/plugins/filter/test_nmstate.py b/kayobe/tests/unit/plugins/filter/test_nmstate.py new file mode 100644 index 000000000..720200ec8 --- /dev/null +++ b/kayobe/tests/unit/plugins/filter/test_nmstate.py @@ -0,0 +1,1014 @@ +# Copyright (c) 2026 StackHPC Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import jinja2 +import unittest + +from kayobe.plugins.filter import nmstate + + +class TestNMStateFilter(unittest.TestCase): + + maxDiff = 2000 + + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + # net1: Ethernet on eth0 with IP 1.2.3.4/24. + "net1_interface": "eth0", + "net1_ips": {"test-host": "1.2.3.4"}, + "net1_cidr": "1.2.3.4/24", + "net1_gateway": "1.2.3.1", + "net1_mtu": 1500, + "net1_ethtool_config": { + "ring": {"rx": 2048, "tx": 2048}, + "feature": {"rx": True, "gso": False} + }, + # net2: VLAN on eth0.2 with VLAN 2 on interface eth0. + "net2_interface": "eth0.2", + "net2_vlan": 2, + "net2_ips": {"test-host": "1.2.4.4"}, + "net2_cidr": "1.2.4.4/24", + # net3: bridge on br0 with ports eth1. + "net3_interface": "br0", + "net3_bridge_ports": ['eth1'], + "net3_bridge_stp": True, + # net4: bond on bond0 with slaves eth2 and eth3. + "net4_interface": "bond0", + "net4_bond_slaves": ['eth2', 'eth3'], + "net4_bond_mode": "layer3+4", + "net4_bond_miimon": 100, + "net4_bond_updelay": 200, + "net4_bond_downdelay": 300, + "net4_bond_xmit_hash_policy": "layer3+4", + "net4_bond_lacp_rate": 1, + "net4_bond_ad_select": "bandwidth", + # net5 & net6: Multiple networks on the same interface eth4 + "net5_interface": "eth4", + "net5_ips": {"test-host": "10.0.0.1"}, + "net5_cidr": "10.0.0.1/24", + "net6_interface": "eth4", + "net6_ips": {"test-host": "10.0.0.2"}, + "net6_cidr": "10.0.0.2/24", + } + + def setUp(self): + self.env = jinja2.Environment(autoescape=True) + self.env.filters["bool"] = self._jinja2_bool + self.context = self._make_context(self.variables) + + def _jinja2_bool(self, value): + if isinstance(value, bool): + return value + if str(value).lower() in ("true", "yes", "on", "1"): + return True + return False + + def _make_context(self, parent): + return self.env.context_class( + self.env, parent=parent, name='dummy', blocks={}) + + def test_nmstate_config_ethernet(self): + result = nmstate.nmstate_config(self.context, ["net1"]) + expected_iface = { + "name": "eth0", + "state": "up", + "type": "ethernet", + "mtu": 1500, + "ipv4": { + "enabled": True, + "dhcp": False, + "address": [{"ip": "1.2.3.4", "prefix-length": 24}] + }, + "ethtool": { + "ring": { + "rx": 2048, + "tx": 2048 + }, + "feature": { + "rx-checksum": True, + "tx-generic-segmentation": False + } + } + } + self.assertIn(expected_iface, result["interfaces"]) + route = result["routes"]["config"][0] + self.assertEqual(route["next-hop-address"], "1.2.3.1") + + def test_nmstate_config_vlan(self): + result = nmstate.nmstate_config(self.context, ["net2"]) + # Should have eth0 and eth0.2 + ifnames = [i["name"] for i in result["interfaces"]] + self.assertIn("eth0", ifnames) + self.assertIn("eth0.2", ifnames) + + vlan_iface = next(i for i in result["interfaces"] + if i["name"] == "eth0.2") + self.assertEqual(vlan_iface["type"], "vlan") + self.assertEqual(vlan_iface["vlan"]["base-iface"], "eth0") + self.assertEqual(vlan_iface["vlan"]["id"], 2) + + def test_nmstate_config_bridge(self): + result = nmstate.nmstate_config(self.context, ["net3"]) + br_iface = next(i for i in result["interfaces"] if i["name"] == "br0") + self.assertEqual(br_iface["type"], "linux-bridge") + self.assertEqual(br_iface["bridge"]["port"], [{"name": "eth1"}]) + self.assertTrue(br_iface["bridge"]["options"]["stp"]["enabled"]) + + # eth1 should be present as ethernet + eth1_iface = next(i for i in result["interfaces"] + if i["name"] == "eth1") + self.assertEqual(eth1_iface["type"], "ethernet") + + def test_nmstate_config_bond(self): + result = nmstate.nmstate_config(self.context, ["net4"]) + bond_iface = next(i for i in result["interfaces"] + if i["name"] == "bond0") + self.assertEqual(bond_iface["type"], "bond") + self.assertEqual(bond_iface["link-aggregation"]["mode"], "layer3+4") + self.assertEqual( + bond_iface["link-aggregation"]["options"], + { + "ad_select": "bandwidth", + "downdelay": 300, + "lacp_rate": 1, + "miimon": 100, + "updelay": 200, + "xmit_hash_policy": "layer3+4", + } + ) + ports = set(bond_iface["link-aggregation"]["port"]) + self.assertEqual(ports, {"eth2", "eth3"}) + + def test_nmstate_config_multiple_nets_same_iface(self): + result = nmstate.nmstate_config(self.context, ["net5", "net6"]) + eth4_iface = next(i for i in result["interfaces"] + if i["name"] == "eth4") + self.assertEqual(eth4_iface["type"], "ethernet") + addresses = eth4_iface["ipv4"]["address"] + self.assertEqual(len(addresses), 2) + ips = {a["ip"] for a in addresses} + self.assertEqual(ips, {"10.0.0.1", "10.0.0.2"}) + + def test_nmstate_config_dummy_interface_infers_dummy_type(self): + variables = { + "inventory_hostname": "test-host", + "test_interface": "dummy2", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + iface = next(i for i in result["interfaces"] if i["name"] == "dummy2") + self.assertEqual(iface["type"], "dummy") + + def test_nmstate_config_dummy_interface_explicit_type_override(self): + variables = { + "inventory_hostname": "test-host", + "test_interface": "dummy2", + "test_type": "ethernet", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + iface = next(i for i in result["interfaces"] if i["name"] == "dummy2") + self.assertEqual(iface["type"], "ethernet") + + def test_nmstate_config_bridge_ports_infer_dummy_type(self): + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "test_interface": "br0", + "test_bridge_ports": ["dummy3", "dummy4"], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + dummy3 = next(i for i in result["interfaces"] if i["name"] == "dummy3") + dummy4 = next(i for i in result["interfaces"] if i["name"] == "dummy4") + self.assertEqual(dummy3["type"], "dummy") + self.assertEqual(dummy4["type"], "dummy") + + def test_nmstate_config_bridge_port_explicit_type_override(self): + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "test_interface": "br0", + "test_bridge_ports": ["dummy3", "dummy4"], + "test_port_type_dummy3": "ethernet", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + dummy3 = next(i for i in result["interfaces"] if i["name"] == "dummy3") + dummy4 = next(i for i in result["interfaces"] if i["name"] == "dummy4") + self.assertEqual(dummy3["type"], "ethernet") + self.assertEqual(dummy4["type"], "dummy") + + def test_nmstate_config_bond_slaves_infer_dummy_type(self): + variables = { + "inventory_hostname": "test-host", + "test_interface": "bond0", + "test_bond_slaves": ["dummy5", "dummy6"], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + dummy5 = next(i for i in result["interfaces"] if i["name"] == "dummy5") + dummy6 = next(i for i in result["interfaces"] if i["name"] == "dummy6") + self.assertEqual(dummy5["type"], "dummy") + self.assertEqual(dummy6["type"], "dummy") + + def test_nmstate_config_bond_slave_explicit_type_override(self): + variables = { + "inventory_hostname": "test-host", + "test_interface": "bond0", + "test_bond_slaves": ["dummy5", "dummy6"], + "test_slave_type_dummy5": "ethernet", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + dummy5 = next(i for i in result["interfaces"] if i["name"] == "dummy5") + dummy6 = next(i for i in result["interfaces"] if i["name"] == "dummy6") + self.assertEqual(dummy5["type"], "ethernet") + self.assertEqual(dummy6["type"], "dummy") + + def test_ethtool_ring_configuration(self): + """Test structured ethtool ring buffer configuration.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "ring": {"rx": 4096, "tx": 2048, "rx-max": 8192} + } + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + eth_iface = next(i for i in result["interfaces"] + + + if i["name"] == "eth0") + expected_ethtool = { + "ring": {"rx": 4096, "tx": 2048, "rx-max": 8192} + } + self.assertEqual(eth_iface["ethtool"], expected_ethtool) + + def test_ethtool_tier1_features(self): + """Test Tier 1 ethtool features with canonical names.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "feature": { + "rx-checksum": True, + "tx-checksum-ip-generic": False, + "rx-gro": True, + "tx-generic-segmentation": False, + "rx-lro": True, + "hw-tc-offload": True + } + } + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + eth_iface = next(i for i in result["interfaces"] + + + if i["name"] == "eth0") + expected_features = { + "rx-checksum": True, + "tx-checksum-ip-generic": False, + "rx-gro": True, + "tx-generic-segmentation": False, + "rx-lro": True, + "hw-tc-offload": True + } + self.assertEqual(eth_iface["ethtool"]["feature"], expected_features) + + def test_ethtool_feature_aliases(self): + """Test ethtool feature alias resolution.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "feature": { + "rx": True, # alias for rx-checksum + "gro": False, # alias for rx-gro + "gso": True, # alias for tx-generic-segmentation + "lro": False # alias for rx-lro + } + } + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + eth_iface = next(i for i in result["interfaces"] + + + if i["name"] == "eth0") + expected_features = { + "rx-checksum": True, + "rx-gro": False, + "tx-generic-segmentation": True, + "rx-lro": False + } + self.assertEqual(eth_iface["ethtool"]["feature"], expected_features) + + def test_ethtool_combined_config(self): + """Test combined ring and feature configuration.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "ring": {"rx": 1024, "tx": 512}, + "feature": {"rx": True, "gso": False} + } + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + eth_iface = next(i for i in result["interfaces"] + + + if i["name"] == "eth0") + expected_ethtool = { + "ring": {"rx": 1024, "tx": 512}, + "feature": {"rx-checksum": True, "tx-generic-segmentation": False} + } + self.assertEqual(eth_iface["ethtool"], expected_ethtool) + + def test_ethtool_invalid_feature(self): + """Test error handling for unsupported features.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "feature": {"unsupported-feature": True} + } + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn( + "Unsupported ethtool features: unsupported-feature", + str(cm.exception)) + + def test_ethtool_invalid_ring_param(self): + """Test error handling for invalid ring parameters.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "ring": {"invalid-param": 1024} + } + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn( + + "Unsupported ring parameters: invalid-param", str(cm.exception)) + + def test_ethtool_invalid_feature_value(self): + """Test error handling for non-boolean feature values.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "feature": {"rx-checksum": "invalid"} + } + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn( + + "Ethtool feature 'rx-checksum' must be boolean", str(cm.exception)) + + def test_ethtool_invalid_ring_value(self): + """Test error handling for invalid ring values.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ethtool_config": { + "ring": {"rx": -1} + } + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn( + "Ring parameter 'rx' must be a non-negative integer", + str(cm.exception)) + + def test_vlan_interface_naming_heuristic(self): + """Test VLAN ID derivation from interface name. + + Tests derivation without explicit vlan attribute. + """ + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "vlan_interface": "eth0.123", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["vlan"]) + + vlan_iface = next( + i for i in result["interfaces"] + if i["name"] == "eth0.123") + self.assertEqual(vlan_iface["type"], "vlan") + self.assertEqual(vlan_iface["vlan"]["base-iface"], "eth0") + self.assertEqual(vlan_iface["vlan"]["id"], 123) + + def test_vlan_interface_explicit_vlan_and_parent(self): + """Test VLAN with explicit vlan and parent attributes.""" + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "vlan_interface": "custom1", + "vlan_vlan": 100, + "vlan_parent": "eth0", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["vlan"]) + + vlan_iface = next( + i for i in result["interfaces"] + if i["name"] == "custom1") + self.assertEqual(vlan_iface["type"], "vlan") + self.assertEqual(vlan_iface["vlan"]["base-iface"], "eth0") + self.assertEqual(vlan_iface["vlan"]["id"], 100) + + def test_vlan_interface_invalid_name(self): + """Test VLAN with invalid interface name is skipped gracefully.""" + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "vlan_interface": "eth0", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["vlan"]) + + eth_iface = next( + i for i in result["interfaces"] + if i["name"] == "eth0") + self.assertEqual(eth_iface["type"], "ethernet") + + def test_vlan_interface_parent_derivation(self): + """Test VLAN parent derivation from interface name.""" + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "vlan_interface": "bond0.42", + "vlan_vlan": 42, + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["vlan"]) + + vlan_iface = next( + i for i in result["interfaces"] + if i["name"] == "bond0.42") + self.assertEqual(vlan_iface["vlan"]["base-iface"], "bond0") + self.assertEqual(vlan_iface["vlan"]["id"], 42) + + bond_iface = next( + i for i in result["interfaces"] + if i["name"] == "bond0") + self.assertEqual(bond_iface["state"], "up") + + def test_bridge_stp_unset(self): + """Test bridge with unset bridge_stp does not configure STP.""" + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "Debian"}, + "test_interface": "br0", + "test_bridge_ports": ["eth1"], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + br_iface = next(i for i in result["interfaces"] if i["name"] == "br0") + self.assertEqual(br_iface["type"], "linux-bridge") + self.assertEqual(br_iface["bridge"]["port"], [{"name": "eth1"}]) + self.assertNotIn("options", br_iface["bridge"]) + + def test_bridge_stp_true(self): + """Test bridge with STP explicitly enabled.""" + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "test_interface": "br0", + "test_bridge_ports": ["eth1"], + "test_bridge_stp": "true", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + br_iface = next(i for i in result["interfaces"] if i["name"] == "br0") + self.assertTrue(br_iface["bridge"]["options"]["stp"]["enabled"]) + + def test_bridge_stp_false(self): + """Test bridge with STP explicitly disabled.""" + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "test_interface": "br0", + "test_bridge_ports": ["eth1"], + "test_bridge_stp": "false", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + br_iface = next(i for i in result["interfaces"] if i["name"] == "br0") + self.assertFalse(br_iface["bridge"]["options"]["stp"]["enabled"]) + + def test_defroute_false_static_ip(self): + """Test defroute=false suppresses default route for static IP.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ips": {"test-host": "10.0.0.1"}, + "test_cidr": "10.0.0.1/24", + "test_gateway": "10.0.0.254", + "test_defroute": "false", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + eth_iface = next( + i for i in result["interfaces"] + if i["name"] == "eth0") + self.assertEqual( + eth_iface["ipv4"]["address"][0]["ip"], "10.0.0.1") + + default_routes = [ + r for r in result["routes"]["config"] + if r["destination"] == "0.0.0.0/0"] + self.assertEqual(len(default_routes), 0) + + def test_defroute_true_static_ip(self): + """Test defroute=true adds default route for static IP.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ips": {"test-host": "10.0.0.1"}, + "test_cidr": "10.0.0.1/24", + "test_gateway": "10.0.0.254", + "test_defroute": "true", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + default_routes = [ + r for r in result["routes"]["config"] + if r["destination"] == "0.0.0.0/0"] + self.assertEqual(len(default_routes), 1) + self.assertEqual(default_routes[0]["next-hop-address"], + "10.0.0.254") + + def test_defroute_unset_static_ip(self): + """Test defroute unset (None) adds default route for static IP.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_ips": {"test-host": "10.0.0.1"}, + "test_cidr": "10.0.0.1/24", + "test_gateway": "10.0.0.254", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + default_routes = [ + r for r in result["routes"]["config"] + if r["destination"] == "0.0.0.0/0"] + self.assertEqual(len(default_routes), 1) + self.assertEqual(default_routes[0]["next-hop-address"], + "10.0.0.254") + + def test_defroute_false_dhcp(self): + """Test defroute=false disables auto-routes for DHCP.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_bootproto": "dhcp", + "test_gateway": "10.0.0.1", + "test_defroute": "false", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + eth_iface = next( + i for i in result["interfaces"] + if i["name"] == "eth0") + self.assertTrue(eth_iface["ipv4"]["dhcp"]) + self.assertFalse(eth_iface["ipv4"]["auto-routes"]) + + default_routes = [ + r for r in result["routes"]["config"] + if r["destination"] == "0.0.0.0/0"] + self.assertEqual(len(default_routes), 0) + + def test_defroute_true_dhcp(self): + """Test defroute=true allows default routes for DHCP.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_bootproto": "dhcp", + "test_gateway": "10.0.0.1", + "test_defroute": "true", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + eth_iface = next( + i for i in result["interfaces"] + if i["name"] == "eth0") + self.assertTrue(eth_iface["ipv4"]["dhcp"]) + self.assertNotIn("auto-routes", eth_iface["ipv4"]) + + default_routes = [ + r for r in result["routes"]["config"] + if r["destination"] == "0.0.0.0/0"] + self.assertEqual(len(default_routes), 1) + self.assertEqual(default_routes[0]["next-hop-address"], + "10.0.0.1") + + def test_ovs_patch_links(self): + variables = { + "inventory_hostname": "test-host", + "ansible_facts": {"os_family": "RedHat"}, + "net3_interface": "br0", + "net3_bridge_ports": ["eth1"], + "provision_wl_net_name": "net3", + "network_patch_prefix": "p-", + "network_patch_suffix_phy": "-phy", + "network_patch_suffix_ovs": "-ovs", + } + context = self._make_context(variables) + + result = nmstate.nmstate_config(context, ["net3"]) + interfaces = {iface["name"]: iface for iface in result["interfaces"]} + + self.assertIn("p-br0-phy", interfaces) + self.assertIn("p-br0-ovs", interfaces) + self.assertEqual(interfaces["p-br0-phy"]["type"], "veth") + self.assertEqual( + interfaces["p-br0-phy"]["veth"], + {"peer": "p-br0-ovs"} + ) + self.assertEqual(interfaces["p-br0-phy"]["ipv4"], {"enabled": False}) + self.assertEqual(interfaces["p-br0-phy"]["ipv6"], {"enabled": False}) + + self.assertEqual(interfaces["p-br0-ovs"]["type"], "veth") + self.assertEqual( + interfaces["p-br0-ovs"]["veth"], + {"peer": "p-br0-phy"} + ) + self.assertEqual(interfaces["p-br0-ovs"]["ipv4"], {"enabled": False}) + self.assertEqual(interfaces["p-br0-ovs"]["ipv6"], {"enabled": False}) + + bridge_ports = interfaces["br0"]["bridge"]["port"] + self.assertIn({"name": "eth1"}, bridge_ports) + self.assertIn({"name": "p-br0-phy"}, bridge_ports) + + def test_route_without_table(self): + """Test route without table-id omits the field.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + {"cidr": "10.0.0.0/24", "gateway": "192.168.1.1"} + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + routes = result["routes"]["config"] + self.assertEqual(len(routes), 1) + self.assertEqual(routes[0]["destination"], "10.0.0.0/24") + self.assertEqual(routes[0]["next-hop-address"], "192.168.1.1") + self.assertNotIn("table-id", routes[0]) + + def test_route_with_supported_attributes(self): + """Test route maps supported nmstate attributes.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + { + "cidr": "10.0.0.0/24", + "gateway": "192.168.1.1", + "metric": "400", + "onlink": "true", + "src": "192.168.1.2", + } + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + routes = result["routes"]["config"] + self.assertEqual(len(routes), 1) + self.assertEqual(routes[0]["metric"], 400) + self.assertTrue(routes[0]["on-link"]) + self.assertEqual(routes[0]["source"], "192.168.1.2") + + def test_route_with_supported_options(self): + """Test documented route options map to nmstate attributes.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + { + "cidr": "10.0.0.0/24", + "gateway": "192.168.1.1", + "options": [ + "onlink", + "metric 400", + "src 192.168.1.2", + ], + } + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + routes = result["routes"]["config"] + self.assertEqual(len(routes), 1) + self.assertEqual(routes[0]["metric"], 400) + self.assertTrue(routes[0]["on-link"]) + self.assertEqual(routes[0]["source"], "192.168.1.2") + + def test_route_with_table_name_lookup(self): + """Test route with table name looks up ID from network_route_tables.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + {"cidr": "10.0.0.0/24", "gateway": "192.168.1.1", + "table": "custom-table"} + ], + "network_route_tables": [ + {"name": "custom-table", "id": 100} + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + routes = result["routes"]["config"] + self.assertEqual(len(routes), 1) + self.assertEqual(routes[0]["table-id"], 100) + + def test_route_with_undefined_table_name(self): + """Test route with undefined table name raises ValueError.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + {"cidr": "10.0.0.0/24", "gateway": "192.168.1.1", + "table": "undefined-table"} + ], + "network_route_tables": [], + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn("undefined-table", str(cm.exception)) + self.assertIn("not defined in network_route_tables", str(cm.exception)) + + def test_route_with_table_id(self): + """Test route with numeric table ID includes table-id.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + {"cidr": "10.0.0.0/24", "gateway": "192.168.1.1", + "table": 100} + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + routes = result["routes"]["config"] + self.assertEqual(len(routes), 1) + self.assertEqual(routes[0]["table-id"], 100) + + def test_route_with_string_numeric_table_id(self): + """Test route with string numeric table ID converts to int.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + {"cidr": "10.0.0.0/24", "gateway": "192.168.1.1", + "table": "100"} + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + routes = result["routes"]["config"] + self.assertEqual(len(routes), 1) + self.assertEqual(routes[0]["table-id"], 100) + + def test_route_string_not_supported(self): + """Test string-format routing route raises ValueError.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + "10.0.0.0/24 via 192.168.1.1" + ], + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn( + "Network 'test' has invalid routing route format at index 0", + str(cm.exception) + ) + self.assertIn( + "String format routes are only supported by the default network " + "engine", + str(cm.exception) + ) + + def test_route_unsupported_option_not_supported(self): + """Test unsupported route options raise ValueError.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + { + "cidr": "10.0.0.0/24", + "gateway": "192.168.1.1", + "options": ["mtu 1400"], + } + ], + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn( + "unsupported routing route option", + str(cm.exception) + ) + + def test_route_conflicting_option_not_supported(self): + """Test conflicting route keys and options raise ValueError.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_routes": [ + { + "cidr": "10.0.0.0/24", + "gateway": "192.168.1.1", + "metric": 100, + "options": ["metric 400"], + } + ], + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn( + "conflicting routing route option", + str(cm.exception) + ) + + def test_rule_string_not_supported(self): + """Test string-format routing rule raises ValueError.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_rules": [ + "from 192.168.1.0/24 table 200" + ], + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn("Network 'test' has invalid routing rule format", + str(cm.exception)) + self.assertIn("String format rules are only supported by the default " + "network engine", str(cm.exception)) + + def test_rule_minimal(self): + """Test rule with minimal fields omits optional ones.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_rules": [ + {"to": "10.0.0.0/24", "table": "custom-table"} + ], + "network_route_tables": [ + {"name": "custom-table", "id": 200} + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + rules = result["route-rules"]["config"] + self.assertEqual(len(rules), 1) + self.assertEqual(rules[0]["ip-to"], "10.0.0.0/24") + self.assertEqual(rules[0]["route-table"], 200) + self.assertNotIn("ip-from", rules[0]) + self.assertNotIn("priority", rules[0]) + + def test_rule_complete(self): + """Test rule with all fields includes them.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_rules": [ + {"from": "192.168.1.0/24", "to": "10.0.0.0/24", + "priority": 100, "table": "custom-table"} + ], + "network_route_tables": [ + {"name": "custom-table", "id": 200} + ], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + rules = result["route-rules"]["config"] + self.assertEqual(len(rules), 1) + self.assertEqual(rules[0]["ip-from"], "192.168.1.0/24") + self.assertEqual(rules[0]["ip-to"], "10.0.0.0/24") + self.assertEqual(rules[0]["priority"], 100) + self.assertEqual(rules[0]["route-table"], 200) + + def test_rule_with_undefined_table_name(self): + """Test rule with undefined table name raises ValueError.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "eth0", + "test_rules": [ + {"to": "10.0.0.0/24", "table": "undefined-table"} + ], + "network_route_tables": [], + } + context = self._make_context(variables) + + with self.assertRaises(ValueError) as cm: + nmstate.nmstate_config(context, ["test"]) + self.assertIn("undefined-table", str(cm.exception)) + self.assertIn("not defined in network_route_tables", str(cm.exception)) + + def test_bond_without_mode(self): + """Test bond without mode gets default balance-rr mode.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "bond0", + "test_bond_slaves": ["eth0", "eth1"], + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + bond_iface = next(i for i in result["interfaces"] + if i["name"] == "bond0") + self.assertEqual(bond_iface["type"], "bond") + # nmstate requires bond mode, so default is provided + self.assertEqual(bond_iface["link-aggregation"]["mode"], "balance-rr") + self.assertEqual(set(bond_iface["link-aggregation"]["port"]), + {"eth0", "eth1"}) + + def test_bond_with_explicit_mode(self): + """Test bond with explicit mode uses specified mode.""" + variables = { + "inventory_hostname": "test-host", + "test_interface": "bond0", + "test_bond_slaves": ["eth0", "eth1"], + "test_bond_mode": "802.3ad", + } + context = self._make_context(variables) + result = nmstate.nmstate_config(context, ["test"]) + + bond_iface = next(i for i in result["interfaces"] + if i["name"] == "bond0") + self.assertEqual(bond_iface["type"], "bond") + self.assertEqual(bond_iface["link-aggregation"]["mode"], "802.3ad") + self.assertEqual(set(bond_iface["link-aggregation"]["port"]), + {"eth0", "eth1"}) diff --git a/kayobe/tests/unit/test_nmstate_apply.py b/kayobe/tests/unit/test_nmstate_apply.py new file mode 100644 index 000000000..0573ea18e --- /dev/null +++ b/kayobe/tests/unit/test_nmstate_apply.py @@ -0,0 +1,133 @@ +# Copyright (c) 2026 StackHPC Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import importlib.util +from pathlib import Path +import unittest +from unittest import mock + + +MODULE_PATH = ( + Path(__file__).resolve().parents[3] / + "ansible/roles/network-nmstate/library/nmstate_apply.py" +) + + +class ModuleFailed(Exception): + def __init__(self, payload): + super().__init__(payload.get("msg", "module failed")) + self.payload = payload + + +class ModuleExited(Exception): + def __init__(self, payload): + super().__init__("module exited") + self.payload = payload + + +class FakeModule: + def __init__(self, params): + self.params = params + + def fail_json(self, **kwargs): + raise ModuleFailed(kwargs) + + def exit_json(self, **kwargs): + raise ModuleExited(kwargs) + + +class TestNMStateApply(unittest.TestCase): + + def _load_module(self): + spec = importlib.util.spec_from_file_location( + "kayobe_nmstate_apply_module", + MODULE_PATH, + ) + if spec is None or spec.loader is None: + raise RuntimeError("Failed to load nmstate_apply module spec") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def test_import_failure(self): + module = self._load_module() + fake_module = FakeModule({"state": {}, "debug": False}) + + with mock.patch.object( + module, "AnsibleModule", return_value=fake_module + ): + with mock.patch.object( + module.importlib, + "import_module", + side_effect=ImportError("No module named libnmstate"), + ): + with self.assertRaises(ModuleFailed) as context: + module.run_module() + + message = context.exception.payload["msg"] + self.assertIn("Failed to import libnmstate module", message) + self.assertIn("python3-libnmstate", message) + + def test_apply_failure(self): + module = self._load_module() + fake_module = FakeModule({"state": {"interfaces": []}, "debug": False}) + + fake_libnmstate = mock.Mock() + fake_libnmstate.show.return_value = {"interfaces": []} + fake_libnmstate.apply.side_effect = RuntimeError("apply failed") + + with mock.patch.object( + module, "AnsibleModule", return_value=fake_module + ): + with mock.patch.object( + module.importlib, + "import_module", + return_value=fake_libnmstate, + ): + with self.assertRaises(ModuleFailed) as context: + module.run_module() + + self.assertIn( + "Failed to apply nmstate state", + context.exception.payload["msg"], + ) + + def test_apply_success_debug_output(self): + module = self._load_module() + desired_state = {"interfaces": [{"name": "eth0", "state": "up"}]} + fake_module = FakeModule({"state": desired_state, "debug": True}) + + previous_state = {"interfaces": [{"name": "eth0", "state": "down"}]} + current_state = {"interfaces": [{"name": "eth0", "state": "up"}]} + + fake_libnmstate = mock.Mock() + fake_libnmstate.show.side_effect = [previous_state, current_state] + + with mock.patch.object( + module, "AnsibleModule", return_value=fake_module + ): + with mock.patch.object( + module.importlib, + "import_module", + return_value=fake_libnmstate, + ): + with self.assertRaises(ModuleExited) as context: + module.run_module() + + payload = context.exception.payload + self.assertTrue(payload["changed"]) + self.assertEqual(payload["state"], current_state) + self.assertEqual(payload["previous_state"], previous_state) + self.assertEqual(payload["desired_state"], desired_state) + fake_libnmstate.apply.assert_called_once_with(desired_state) diff --git a/playbooks/kayobe-overcloud-base/overrides.yml.j2 b/playbooks/kayobe-overcloud-base/overrides.yml.j2 index 16f94d9c2..c08a83e5b 100644 --- a/playbooks/kayobe-overcloud-base/overrides.yml.j2 +++ b/playbooks/kayobe-overcloud-base/overrides.yml.j2 @@ -1,4 +1,10 @@ --- +{% set overcloud_network_engine = ci_network_engine | default('default') %} + +{% if overcloud_network_engine != 'default' %} +network_engine: {{ overcloud_network_engine }} +{% endif %} + docker_daemon_debug: true # Use the OpenStack infra's Dockerhub mirror. docker_registry_mirrors: diff --git a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 index 06933a1d0..b0e4cd21d 100644 --- a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 +++ b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 @@ -1,6 +1,7 @@ --- # The following configuration aims to test some of the 'host configure' # command. +{% set host_configure_network_engine = ci_network_engine | default('default') %} # Additional users. controller_users: @@ -10,6 +11,12 @@ controller_users: groups: - stack +# Exercise nmstate networking engine in CI host configure jobs where +# required packages are available. +{% if host_configure_network_engine == "nmstate" %} +network_engine: {{ host_configure_network_engine }} +{% endif %} + # Additional network interfaces, testing a variety of interface configurations. controller_extra_network_interfaces: - test_net_eth @@ -28,7 +35,7 @@ network_route_tables: - id: 2 name: kayobe-test-route-table -# dummy2: Ethernet interface. +# dummy2: Dummy interface for testing. test_net_eth_cidr: 192.168.34.0/24 test_net_eth_routes: - cidr: 192.168.40.0/24 @@ -44,7 +51,12 @@ test_net_eth_vlan_routes: gateway: 192.168.35.254 table: kayobe-test-route-table test_net_eth_vlan_rules: -{% if ansible_facts.os_family == 'RedHat' %} +{% if ansible_facts.os_family == 'RedHat' and host_configure_network_engine == 'nmstate' %} + - from: 192.168.35.0/24 + table: kayobe-test-route-table + - to: 192.168.35.0/24 + table: kayobe-test-route-table +{% elif ansible_facts.os_family == 'RedHat' %} - from 192.168.35.0/24 table 2 - to: 192.168.35.0/24 table: kayobe-test-route-table @@ -73,6 +85,7 @@ test_net_bridge_vlan_zone: test-zone3 test_net_bond_cidr: 192.168.38.0/24 test_net_bond_interface: bond0 test_net_bond_bond_slaves: [dummy5, dummy6] +test_net_bond_bond_mode: balance-rr test_net_bond_zone: test-zone3 # bond0.44: VLAN subinterface of bond0. diff --git a/releasenotes/notes/nmstate-networking-engine-9eca23fe61902134.yaml b/releasenotes/notes/nmstate-networking-engine-9eca23fe61902134.yaml new file mode 100644 index 000000000..b6ec7a908 --- /dev/null +++ b/releasenotes/notes/nmstate-networking-engine-9eca23fe61902134.yaml @@ -0,0 +1,49 @@ +--- +features: + - | + Adds an opt-in ``nmstate`` network engine + (``network_engine: nmstate``) for host network configuration via + NetworkManager/libnmstate. + - | + Supports Ethernet, VLAN, bond, bridge, routes, and routing rules, + and adds OVS patch-link veth generation for overcloud bridge-to-OVS + connectivity. + - | + Adds structured ethtool configuration via + ``_ethtool_config`` for ring parameters and selected + offload features. + - | + The nmstate network engine is only supported on Rocky Linux. Ubuntu Noble + is not supported because the required system packages (nmstate, + python3-libnmstate) are not available in Ubuntu repositories. Attempting + to use nmstate on Ubuntu will fail with a clear error message directing + users to use the ``legacy`` network engine. + +upgrade: + - | + Introduces ``network_engine`` in ``globals.yml``: + ``legacy`` (default) and ``nmstate``. + - | + With ``nmstate``, ethtool settings use structured YAML in + ``_ethtool_config``. ``legacy`` engine behavior is + unchanged. + - | + With ``network_engine: nmstate``, ``_rules`` entries must use + dict format (keys such as ``from``, ``to``, ``priority``, ``table``). + String-format rules are rejected on the ``nmstate`` path. ``legacy`` + engine behavior is unchanged. + - | + Switching to ``nmstate`` may reconfigure host networking and cause + temporary connectivity disruption. + +fixes: + - | + Fixes host configure regressions when using ``network_engine: nmstate`` on + Rocky Linux. Kayobe now ensures named route tables from + ``network_route_tables`` are defined in ``/etc/iproute2/rt_tables`` for + nmstate-managed hosts. These fixes apply only to the ``nmstate`` engine + path; ``legacy`` engine behavior is unchanged. + - | + When using ``network_engine: nmstate`` with firewalld enabled, Kayobe now + reconciles interface-to-zone mappings in both permanent and runtime + firewalld configuration for interfaces that define ``_zone``. diff --git a/zuul.d/jobs.yaml b/zuul.d/jobs.yaml index 08a4328ee..042114e69 100644 --- a/zuul.d/jobs.yaml +++ b/zuul.d/jobs.yaml @@ -108,6 +108,7 @@ previous_release: "{{ '2025.1' if is_slurp else '2025.2' }}" tls_enabled: false container_engine: 'docker' + ci_network_engine: default ironic_boot_mode: "bios" - job: @@ -140,6 +141,7 @@ nodeset: kayobe-rocky10-16GB vars: container_engine: podman + ci_network_engine: nmstate - job: name: kayobe-overcloud-ubuntu-noble @@ -315,6 +317,7 @@ nodeset: kayobe-rocky10 vars: fail2ban_enabled: true + ci_network_engine: nmstate - job: name: kayobe-overcloud-host-configure-ubuntu-noble