feat(configuration): multi-backend networking, bind by match not MAC

This commit is contained in:
2026-05-31 12:25:53 +02:00
parent 89e366d0f0
commit 579c499c02
10 changed files with 202 additions and 42 deletions

View File

@@ -1,5 +1,3 @@
---
# Network configuration dispatch - maps OS name to the task file
# that writes network config. Default (NetworkManager) applies to
# all OSes not explicitly listed.
configuration_network_task_map: {}
# Network backend is detected per host from the target rootfs in network.yml;
# no static map needed.

View File

@@ -1,38 +1,51 @@
---
- name: Read network interfaces
ansible.builtin.command:
argv:
- ip
- -o
- link
- show
register: configuration_ip_link
changed_when: false
failed_when: false
- name: Detect available network interface names
vars:
configuration_detected_interfaces: >-
{{
configuration_ip_link.stdout
| default('')
| regex_findall('^[0-9]+: ([^:]+):', multiline=True)
| reject('equalto', 'lo')
| list
}}
ansible.builtin.set_fact:
configuration_detected_interfaces: "{{ configuration_detected_interfaces }}"
- name: Validate at least one network interface detected
ansible.builtin.assert:
that:
- configuration_detected_interfaces | length > 0
fail_msg: Failed to detect any network interfaces.
- name: Set DNS configuration facts
ansible.builtin.set_fact:
configuration_dns_list: "{{ system_cfg.network.dns.servers }}"
configuration_dns_search: "{{ system_cfg.network.dns.search }}"
- name: Configure networking
ansible.builtin.include_tasks: "{{ configuration_network_task_map[os] | default('network_nm.yml') }}"
# 2+ unnamed interfaces would all match the first-ethernet glob, leaving the rest unconfigured.
- name: Require an explicit name on every interface for multi-NIC
vars:
_unnamed: "{{ system_cfg.network.interfaces | map(attribute='name', default='') | map('string') | select('equalto', '') | list | length }}"
ansible.builtin.assert:
that:
- system_cfg.network.interfaces | length <= 1 or _unnamed == 0
fail_msg: >-
Multi-NIC (system.network.interfaces with 2+ entries) requires a name on
every interface; the first-adapter glob only binds a single NIC.
# Probe /mnt to detect the stack the installed rootfs will run (nothing runs in
# the chroot). NM is checked first and wins, since bootstrap installs it on every
# family; the rest are the fallback for a non-NM base image.
- name: Probe the installed network stack on the target rootfs
ansible.builtin.stat:
path: "{{ item }}"
register: configuration_net_probe
loop:
- /mnt/usr/bin/nmcli
- /mnt/usr/lib/systemd/system/NetworkManager.service
- /mnt/usr/sbin/netplan
- /mnt/etc/netplan
- /mnt/sbin/ifup
- /mnt/usr/sbin/ifup
- /mnt/etc/systemd/system/multi-user.target.wants/systemd-networkd.service
- /mnt/etc/systemd/system/dbus-org.freedesktop.network1.service
loop_control:
label: "{{ item }}"
- name: Resolve the network backend from the probe
vars:
_found: "{{ configuration_net_probe.results | selectattr('stat.exists') | map(attribute='item') | list }}"
ansible.builtin.set_fact:
configuration_network_backend: >-
{{
'nm' if (['/mnt/usr/bin/nmcli', '/mnt/usr/lib/systemd/system/NetworkManager.service'] | intersect(_found))
else 'netplan' if (['/mnt/usr/sbin/netplan', '/mnt/etc/netplan'] | intersect(_found))
else 'eni' if (['/mnt/sbin/ifup', '/mnt/usr/sbin/ifup'] | intersect(_found))
else 'networkd' if (['/mnt/etc/systemd/system/multi-user.target.wants/systemd-networkd.service', '/mnt/etc/systemd/system/dbus-org.freedesktop.network1.service'] | intersect(_found))
else 'nm'
}}
- name: Configure networking for the detected backend {{ configuration_network_backend }}
ansible.builtin.include_tasks: "network_{{ configuration_network_backend }}.yml"

View File

@@ -0,0 +1,35 @@
---
# ifupdown can't glob iface stanzas (no mapping on ifupdown2/Proxmox), so ENI binds
# a literal name detected here. The chroot only sees live-ISO names: on a real
# ifupdown base, set system.network.interfaces[].name to the installed name. Bootstrap
# installs NetworkManager, so this fires only on a non-NM base image.
- name: Detect ethernet interface names
ansible.builtin.command:
argv:
- ip
- -o
- link
- show
register: configuration_eni_link
changed_when: false
failed_when: false
- name: Resolve detected ethernet interface names
ansible.builtin.set_fact:
configuration_eni_detected: >-
{{
configuration_eni_link.stdout | default('')
| regex_findall('^[0-9]+: ([^:@]+)[@:].*?link/ether', multiline=True)
}}
- name: Ensure the network configuration directory exists
ansible.builtin.file:
path: /mnt/etc/network
state: directory
mode: "0755"
- name: Write the ifupdown interfaces file
ansible.builtin.template:
src: network_eni.j2
dest: /mnt/etc/network/interfaces
mode: "0644"

View File

@@ -0,0 +1,12 @@
---
- name: Ensure the netplan directory exists
ansible.builtin.file:
path: /mnt/etc/netplan
state: directory
mode: "0755"
- name: Write the netplan configuration
ansible.builtin.template:
src: network_netplan.j2
dest: /mnt/etc/netplan/10-sg.yaml
mode: "0600"

View File

@@ -0,0 +1,18 @@
---
- name: Ensure the systemd-networkd directory exists
ansible.builtin.file:
path: /mnt/etc/systemd/network
state: directory
mode: "0755"
- name: Write systemd-networkd configuration per interface
vars:
configuration_iface: "{{ item }}"
ansible.builtin.template:
src: network_networkd.j2
dest: "/mnt/etc/systemd/network/10-static-{{ idx }}.network"
mode: "0644"
loop: "{{ system_cfg.network.interfaces }}"
loop_control:
index_var: idx
label: "10-static-{{ idx }}"

View File

@@ -2,7 +2,6 @@
- name: Copy NetworkManager keyfile per interface
vars:
configuration_iface: "{{ item }}"
configuration_iface_name: "{{ item.name | default(configuration_detected_interfaces[idx] | default('')) }}"
configuration_net_uuid: "{{ ('LAN-' ~ idx ~ '-' ~ hostname) | ansible.builtin.to_uuid }}"
ansible.builtin.template:
src: network.j2

View File

@@ -3,12 +3,18 @@ id=LAN-{{ idx }}
uuid={{ configuration_net_uuid }}
type=ethernet
autoconnect-priority=10
{% if configuration_iface_name | length > 0 %}
interface-name={{ configuration_iface_name }}
{% endif %}
[ipv4]
{% set iface = configuration_iface %}
{% if iface.name | default('') | string | length %}
interface-name={{ iface.name }}
{% else %}
{# Bind the first available ethernet by name glob, never a MAC: a clone with a new adapter/MAC stays networked (#12). #}
[match]
interface-name=en*;eth*;
{% endif %}
[ipv4]
{% set dns_list = configuration_dns_list %}
{% set search_list = configuration_dns_search %}
{% if iface.ip | default('') | string | length %}

View File

@@ -0,0 +1,23 @@
auto lo
iface lo inet loopback
{% for iface in system_cfg.network.interfaces %}
{% set ifname = iface.name if (iface.name | default('') | string | length) else (configuration_eni_detected[loop.index0] | default('eth' ~ loop.index0)) %}
auto {{ ifname }}
{% if iface.ip | default('') | string | length %}
iface {{ ifname }} inet static
address {{ iface.ip }}/{{ iface.prefix }}
{% if iface.gateway | default('') | string | length %}
gateway {{ iface.gateway }}
{% endif %}
{% if loop.index0 == 0 and configuration_dns_list %}
dns-nameservers {{ configuration_dns_list | join(' ') }}
{% endif %}
{% if loop.index0 == 0 and configuration_dns_search %}
dns-search {{ configuration_dns_search | join(' ') }}
{% endif %}
{% else %}
iface {{ ifname }} inet dhcp
{% endif %}
{% endfor %}

View File

@@ -0,0 +1,29 @@
network:
version: 2
ethernets:
{% for iface in system_cfg.network.interfaces %}
lan{{ loop.index0 }}:
{# Unnamed binds the first ethernet by name glob (e* = en*/eth*, netplan match.name takes one glob), never a MAC (#12). #}
match:
name: "{{ iface.name if (iface.name | default('') | string | length) else 'e*' }}"
{% if iface.ip | default('') | string | length %}
addresses:
- {{ iface.ip }}/{{ iface.prefix }}
{% if iface.gateway | default('') | string | length %}
routes:
- to: default
via: {{ iface.gateway }}
{% endif %}
{% else %}
dhcp4: true
{% endif %}
{% if loop.index0 == 0 and (configuration_dns_list or configuration_dns_search) %}
nameservers:
{% if configuration_dns_list %}
addresses: [{{ configuration_dns_list | join(', ') }}]
{% endif %}
{% if configuration_dns_search %}
search: [{{ configuration_dns_search | join(', ') }}]
{% endif %}
{% endif %}
{% endfor %}

View File

@@ -0,0 +1,27 @@
[Match]
{% set iface = configuration_iface %}
{% if iface.name | default('') | string | length %}
Name={{ iface.name }}
{% else %}
{# First available ethernet by name glob + device type, never a MAC (#12). #}
Name=en* eth*
Type=ether
{% endif %}
[Network]
{% if iface.ip | default('') | string | length %}
Address={{ iface.ip }}/{{ iface.prefix }}
{% if iface.gateway | default('') | string | length %}
Gateway={{ iface.gateway }}
{% endif %}
{% else %}
DHCP=yes
{% endif %}
{% if idx | int == 0 and configuration_dns_list %}
{% for dns in configuration_dns_list %}
DNS={{ dns }}
{% endfor %}
{% if configuration_dns_search %}
Domains={{ configuration_dns_search | join(' ') }}
{% endif %}
{% endif %}