diff --git a/README.md b/README.md index ef0ce39..975efee 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ Uniform package content source, family-resolved. `source: ''` defaults to `dvd` | `dns.search` | list | `[]` | Search domains (must be a YAML list) | | `interfaces` | list | `[]` | Multi-NIC config (overrides flat fields above) | -When `interfaces` is empty, the flat fields (`bridge`, `ip`, `prefix`, `gateway`, `vlan`) are auto-wrapped into a single-entry list. When `interfaces` is set, it takes precedence. Each entry supports: `name`, `bridge` (required), `vlan`, `ip`, `prefix`, `gateway`. +When `interfaces` is empty, the flat fields (`bridge`, `ip`, `prefix`, `gateway`, `vlan`) are auto-wrapped into a single-entry list. When `interfaces` is set, it takes precedence. Each entry supports: `name`, `bridge` (required), `vlan`, `ip`, `prefix`, `gateway`, and `extra_addresses`: further IPv4 CIDRs (`192.0.2.10/24`) on the same NIC beside `ip`, written by every network backend. Proxmox `ipconfigN` carries one address per NIC, so on Proxmox the extras arrive with the installed network config, not cloud-init. #### `system.users` diff --git a/roles/configuration/templates/network.j2 b/roles/configuration/templates/network.j2 index 68f84f5..dad2247 100644 --- a/roles/configuration/templates/network.j2 +++ b/roles/configuration/templates/network.j2 @@ -19,6 +19,9 @@ interface-name=en*;eth*; {% set search_list = configuration_dns_search %} {% if iface.ip | default('') | string | length %} address1={{ iface.ip }}/{{ iface.prefix }}{{ (',' ~ iface.gateway) if (iface.gateway | default('') | string | length) else '' }} +{% for address in iface.extra_addresses | default([]) %} +address{{ loop.index + 1 }}={{ address }} +{% endfor %} method=manual {% else %} method=auto diff --git a/roles/configuration/templates/network_eni.j2 b/roles/configuration/templates/network_eni.j2 index 25dbacb..d226196 100644 --- a/roles/configuration/templates/network_eni.j2 +++ b/roles/configuration/templates/network_eni.j2 @@ -16,6 +16,11 @@ iface {{ ifname }} inet static {% if loop.index0 == 0 and configuration_dns_search %} dns-search {{ configuration_dns_search | join(' ') }} {% endif %} +{# One stanza per extra address: classic ifupdown takes one address per stanza, ifupdown2 merges them. #} +{% for address in iface.extra_addresses | default([]) %} +iface {{ ifname }} inet static + address {{ address }} +{% endfor %} {% else %} iface {{ ifname }} inet dhcp {% endif %} diff --git a/roles/configuration/templates/network_netplan.j2 b/roles/configuration/templates/network_netplan.j2 index 59e95ca..9d3ffd9 100644 --- a/roles/configuration/templates/network_netplan.j2 +++ b/roles/configuration/templates/network_netplan.j2 @@ -9,6 +9,9 @@ network: {% if iface.ip | default('') | string | length %} addresses: - {{ iface.ip }}/{{ iface.prefix }} +{% for address in iface.extra_addresses | default([]) %} + - {{ address }} +{% endfor %} {% if iface.gateway | default('') | string | length %} routes: - to: default diff --git a/roles/configuration/templates/network_networkd.j2 b/roles/configuration/templates/network_networkd.j2 index 2b6ade4..a81a959 100644 --- a/roles/configuration/templates/network_networkd.j2 +++ b/roles/configuration/templates/network_networkd.j2 @@ -11,6 +11,9 @@ Type=ether [Network] {% if iface.ip | default('') | string | length %} Address={{ iface.ip }}/{{ iface.prefix }} +{% for address in iface.extra_addresses | default([]) %} +Address={{ address }} +{% endfor %} {% if iface.gateway | default('') | string | length %} Gateway={{ iface.gateway }} {% endif %} diff --git a/roles/global_defaults/tasks/validation.yml b/roles/global_defaults/tasks/validation.yml index 4265104..1a7b9a1 100644 --- a/roles/global_defaults/tasks/validation.yml +++ b/roles/global_defaults/tasks/validation.yml @@ -467,6 +467,21 @@ loop_control: label: "{{ item | to_json }}" +# A string here would iterate into one address per character in every network renderer. +- name: Validate network interfaces extra_addresses + when: item.extra_addresses is defined + ansible.builtin.assert: + that: + - item.extra_addresses is not string + - item.extra_addresses is iterable + - item.ip | default('') | string | length > 0 + - item.extra_addresses | reject('match', '^(\\d{1,3}\\.){3}\\d{1,3}/\\d{1,2}$') | list | length == 0 + fail_msg: "system.network.interfaces[].extra_addresses must be a list of IPv4 CIDRs (192.0.2.10/24) on an entry that also sets 'ip'." + quiet: true + loop: "{{ system_cfg.network.interfaces | default([]) }}" + loop_control: + label: "{{ item | to_json }}" + - name: Validate hostname format ansible.builtin.assert: that: diff --git a/roles/virtualization/templates/cloud-network-config.yml.j2 b/roles/virtualization/templates/cloud-network-config.yml.j2 index 7f5c142..06ed320 100644 --- a/roles/virtualization/templates/cloud-network-config.yml.j2 +++ b/roles/virtualization/templates/cloud-network-config.yml.j2 @@ -12,6 +12,9 @@ network: {% if has_static %} addresses: - "{{ iface.ip }}/{{ iface.prefix }}" +{% for address in iface.extra_addresses | default([]) %} + - "{{ address }}" +{% endfor %} {% if iface.gateway | default('') | string | length %} routes: - to: default