diff --git a/roles/configuration/defaults/main.yml b/roles/configuration/defaults/main.yml index 2b0d3cc..430e6b2 100644 --- a/roles/configuration/defaults/main.yml +++ b/roles/configuration/defaults/main.yml @@ -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. diff --git a/roles/configuration/tasks/network.yml b/roles/configuration/tasks/network.yml index b53b8f5..32a3e42 100644 --- a/roles/configuration/tasks/network.yml +++ b/roles/configuration/tasks/network.yml @@ -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" diff --git a/roles/configuration/tasks/network_eni.yml b/roles/configuration/tasks/network_eni.yml new file mode 100644 index 0000000..cf2437f --- /dev/null +++ b/roles/configuration/tasks/network_eni.yml @@ -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" diff --git a/roles/configuration/tasks/network_netplan.yml b/roles/configuration/tasks/network_netplan.yml new file mode 100644 index 0000000..a271cde --- /dev/null +++ b/roles/configuration/tasks/network_netplan.yml @@ -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" diff --git a/roles/configuration/tasks/network_networkd.yml b/roles/configuration/tasks/network_networkd.yml new file mode 100644 index 0000000..33bb272 --- /dev/null +++ b/roles/configuration/tasks/network_networkd.yml @@ -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 }}" diff --git a/roles/configuration/tasks/network_nm.yml b/roles/configuration/tasks/network_nm.yml index f9be139..83b0700 100644 --- a/roles/configuration/tasks/network_nm.yml +++ b/roles/configuration/tasks/network_nm.yml @@ -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 diff --git a/roles/configuration/templates/network.j2 b/roles/configuration/templates/network.j2 index 8b2ffb3..68f84f5 100644 --- a/roles/configuration/templates/network.j2 +++ b/roles/configuration/templates/network.j2 @@ -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 %} diff --git a/roles/configuration/templates/network_eni.j2 b/roles/configuration/templates/network_eni.j2 new file mode 100644 index 0000000..25dbacb --- /dev/null +++ b/roles/configuration/templates/network_eni.j2 @@ -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 %} diff --git a/roles/configuration/templates/network_netplan.j2 b/roles/configuration/templates/network_netplan.j2 new file mode 100644 index 0000000..59e95ca --- /dev/null +++ b/roles/configuration/templates/network_netplan.j2 @@ -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 %} diff --git a/roles/configuration/templates/network_networkd.j2 b/roles/configuration/templates/network_networkd.j2 new file mode 100644 index 0000000..2b6ade4 --- /dev/null +++ b/roles/configuration/templates/network_networkd.j2 @@ -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 %}