From c81df6fa9cfcde8a2e85a4602d77fd71b9a60439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Can=C3=A9vet?= Date: Tue, 10 Mar 2026 08:45:45 +0100 Subject: [PATCH] refactor(machined): extract per-interface IPv4 helper in OpenNebula driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the per-interface IPv4 logic from ParseMetadata into a dedicated parseInterfaceIPv4 helper, and add an empty parseInterfaceIPv6 stub. ParseMetadata now delegates all per-interface work to those two helpers plus the existing parseAliases, keeping its own body small. No behaviour change; all existing tests pass. Signed-off-by: Mickaël Canévet Signed-off-by: Andrey Smirnov (cherry picked from commit 469db18d3936ed38cb1b6839ce235ac7ada306e6) --- .../v1alpha1/platform/opennebula/ipv6_test.go | 160 ++++++ .../platform/opennebula/opennebula.go | 472 ++++++++++++------ 2 files changed, 467 insertions(+), 165 deletions(-) create mode 100644 internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/ipv6_test.go diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/ipv6_test.go b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/ipv6_test.go new file mode 100644 index 000000000..59bc2088e --- /dev/null +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/ipv6_test.go @@ -0,0 +1,160 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package opennebula_test + +import ( + "net/netip" + "testing" + + "github.com/cosi-project/runtime/pkg/state" + "github.com/cosi-project/runtime/pkg/state/impl/inmem" + "github.com/cosi-project/runtime/pkg/state/impl/namespaced" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula" + "github.com/siderolabs/talos/pkg/machinery/nethelpers" + "github.com/siderolabs/talos/pkg/machinery/resources/network" +) + +const ipv6ContextBase = `ETH0_MAC = "02:00:c0:a8:01:5c" +ETH0_IP = "192.168.1.92" +ETH0_MASK = "255.255.255.0" +NAME = "test" +` + +func ipv6Context(extra string) []byte { + return []byte(ipv6ContextBase + extra) +} + +func TestParseIPv6Static(t *testing.T) { + t.Parallel() + + o := &opennebula.OpenNebula{} + st := state.WrapCore(namespaced.NewState(inmem.Build)) + + defaultGWRoute := func(gw string, priority uint32) network.RouteSpecSpec { + return network.RouteSpecSpec{ + ConfigLayer: network.ConfigPlatform, + Gateway: netip.MustParseAddr(gw), + OutLinkName: "eth0", + Table: nethelpers.TableMain, + Protocol: nethelpers.ProtocolStatic, + Type: nethelpers.TypeUnicast, + Family: nethelpers.FamilyInet6, + Priority: priority, + Scope: nethelpers.ScopeGlobal, + } + } + + for _, tc := range []struct { + name string + extra string + wantAddrs []netip.Prefix + wantRoutes []network.RouteSpecSpec + }{ + { + name: "static IPv6 address with explicit prefix length", + extra: "ETH0_IP6 = \"2001:db8::1\"\nETH0_IP6_PREFIX_LENGTH = \"48\"", + wantAddrs: []netip.Prefix{netip.MustParsePrefix("2001:db8::1/48")}, + }, + { + name: "ETH*_IPV6 legacy alias used when ETH*_IP6 absent", + extra: "ETH0_IPV6 = \"2001:db8::1\"", + wantAddrs: []netip.Prefix{netip.MustParsePrefix("2001:db8::1/64")}, + }, + { + name: "prefix length defaults to 64", + extra: "ETH0_IP6 = \"2001:db8::1\"", + wantAddrs: []netip.Prefix{netip.MustParsePrefix("2001:db8::1/64")}, + }, + { + name: "explicit prefix length respected", + extra: "ETH0_IP6 = \"2001:db8::1\"\nETH0_IP6_PREFIX_LENGTH = \"56\"", + wantAddrs: []netip.Prefix{netip.MustParsePrefix("2001:db8::1/56")}, + }, + { + name: "ULA address emitted as second AddressSpecSpec", + extra: "ETH0_IP6 = \"2001:db8::1\"\nETH0_IP6_ULA = \"fd00::1\"", + wantAddrs: []netip.Prefix{netip.MustParsePrefix("2001:db8::1/64"), netip.MustParsePrefix("fd00::1/64")}, + }, + { + name: "IPv6 gateway emits default route with metric 1", + extra: "ETH0_IP6 = \"2001:db8::1\"\nETH0_IP6_GATEWAY = \"2001:db8::fffe\"", + wantAddrs: []netip.Prefix{netip.MustParsePrefix("2001:db8::1/64")}, + wantRoutes: []network.RouteSpecSpec{defaultGWRoute("2001:db8::fffe", 1)}, + }, + { + name: "ETH*_GATEWAY6 legacy alias used when ETH*_IP6_GATEWAY absent", + extra: "ETH0_IP6 = \"2001:db8::1\"\nETH0_GATEWAY6 = \"2001:db8::fffe\"", + wantAddrs: []netip.Prefix{netip.MustParsePrefix("2001:db8::1/64")}, + wantRoutes: []network.RouteSpecSpec{defaultGWRoute("2001:db8::fffe", 1)}, + }, + { + name: "ETH*_IP6_METRIC overrides default metric of 1", + extra: "ETH0_IP6 = \"2001:db8::1\"\nETH0_IP6_GATEWAY = \"2001:db8::fffe\"\nETH0_IP6_METRIC = \"100\"", + wantAddrs: []netip.Prefix{netip.MustParsePrefix("2001:db8::1/64")}, + wantRoutes: []network.RouteSpecSpec{defaultGWRoute("2001:db8::fffe", 100)}, + }, + { + name: "no IPv6 variables — no IPv6 addresses or routes", + extra: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + networkConfig, err := o.ParseMetadata(st, ipv6Context(tc.extra)) + require.NoError(t, err) + + var ip6Addrs []netip.Prefix + + for _, a := range networkConfig.Addresses { + if a.Family == nethelpers.FamilyInet6 { + ip6Addrs = append(ip6Addrs, a.Address) + } + } + + assert.Equal(t, tc.wantAddrs, ip6Addrs) + + var ip6Routes []network.RouteSpecSpec + + for _, r := range networkConfig.Routes { + if r.Family == nethelpers.FamilyInet6 { + ip6Routes = append(ip6Routes, r) + } + } + + assert.Equal(t, tc.wantRoutes, ip6Routes) + }) + } +} + +func TestParseIPv6Errors(t *testing.T) { + t.Parallel() + + o := &opennebula.OpenNebula{} + st := state.WrapCore(namespaced.NewState(inmem.Build)) + + t.Run("malformed IPv6 address returns descriptive error", func(t *testing.T) { + t.Parallel() + + ctx := ipv6Context("ETH0_IP6 = \"notanip\"") + + _, err := o.ParseMetadata(st, ctx) + require.ErrorContains(t, err, "ETH0") + require.ErrorContains(t, err, "IPv6") + }) + + t.Run("malformed IPv6 gateway returns descriptive error", func(t *testing.T) { + t.Parallel() + + ctx := ipv6Context("ETH0_IP6 = \"2001:db8::1\"\nETH0_IP6_GATEWAY = \"notanip\"") + + _, err := o.ParseMetadata(st, ctx) + require.ErrorContains(t, err, "ETH0") + require.ErrorContains(t, err, "gateway") + }) +} diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/opennebula.go b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/opennebula.go index b02cb860f..60eaac5b5 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/opennebula.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/opennebula.go @@ -267,11 +267,304 @@ func ParseRoutes(routesStr, linkName string) ([]network.RouteSpecSpec, error) { return routes, nil } +// parseIPv4StaticConfig handles the static addressing path for an interface: +// address, link, gateway route, extra static routes, and per-interface DNS. +func parseIPv4StaticConfig( + oneContext map[string]string, ifaceName, ifaceNameLower string, routeMetric uint32, + networkConfig *runtime.PlatformNetworkConfig, allDNSIPs *[]netip.Addr, allSearchDomains *[]string, +) error { + ipPrefix, err := address.IPPrefixFrom(oneContext[ifaceName+"_IP"], oneContext[ifaceName+"_MASK"]) + if err != nil { + return fmt.Errorf("failed to parse IP address: %w", err) + } + + networkConfig.Addresses = append(networkConfig.Addresses, + network.AddressSpecSpec{ + Address: ipPrefix, + LinkName: ifaceNameLower, + Family: nethelpers.FamilyInet4, + Scope: nethelpers.ScopeGlobal, + Flags: nethelpers.AddressFlags(nethelpers.AddressPermanent), + AnnounceWithARP: false, + ConfigLayer: network.ConfigPlatform, + }, + ) + + var mtu uint32 + + if mtuStr := oneContext[ifaceName+"_MTU"]; mtuStr != "" { + mtu64, err := strconv.ParseUint(mtuStr, 10, 32) + if err != nil { + return fmt.Errorf("failed to parse MTU: %w", err) + } + + mtu = uint32(mtu64) + } + + networkConfig.Links = append(networkConfig.Links, + network.LinkSpecSpec{ + Name: ifaceNameLower, + Logical: false, + Up: true, + MTU: mtu, + Kind: "", + Type: nethelpers.LinkEther, + ParentName: "", + ConfigLayer: network.ConfigPlatform, + }, + ) + + if gwStr := oneContext[ifaceName+"_GATEWAY"]; gwStr != "" { + gateway, err := netip.ParseAddr(gwStr) + if err != nil { + return fmt.Errorf("failed to parse gateway ip: %w", err) + } + + route := network.RouteSpecSpec{ + ConfigLayer: network.ConfigPlatform, + Gateway: gateway, + OutLinkName: ifaceNameLower, + Table: nethelpers.TableMain, + Protocol: nethelpers.ProtocolStatic, + Type: nethelpers.TypeUnicast, + Family: nethelpers.FamilyInet4, + Priority: routeMetric, + } + + route.Normalize() + + networkConfig.Routes = append(networkConfig.Routes, route) + } + + if routesStr := oneContext[ifaceName+"_ROUTES"]; routesStr != "" { + staticRoutes, err := ParseRoutes(routesStr, ifaceNameLower) + if err != nil { + return fmt.Errorf("interface %s: %w", ifaceName, err) + } + + networkConfig.Routes = append(networkConfig.Routes, staticRoutes...) + } + + for s := range strings.FieldsSeq(oneContext[ifaceName+"_DNS"]) { + ip, err := netip.ParseAddr(s) + if err != nil { + return fmt.Errorf("interface %s: failed to parse DNS server %q: %w", ifaceName, s, err) + } + + *allDNSIPs = append(*allDNSIPs, ip) + } + + *allSearchDomains = append(*allSearchDomains, strings.Fields(oneContext[ifaceName+"_SEARCH_DOMAIN"])...) + + return nil +} + +// parseInterfaceIPv4 configures the IPv4 stack for one interface. +// Dispatches to DHCP4 operator or static config based on ETH*_METHOD. +func parseInterfaceIPv4(oneContext map[string]string, ifaceName, ifaceNameLower string, networkConfig *runtime.PlatformNetworkConfig, allDNSIPs *[]netip.Addr, allSearchDomains *[]string) error { + routeMetric := uint32(network.DefaultRouteMetric) + + if metricStr := oneContext[ifaceName+"_METRIC"]; metricStr != "" { + m, err := strconv.ParseUint(metricStr, 10, 32) + if err != nil { + return fmt.Errorf("interface %s: failed to parse metric: %w", ifaceName, err) + } + + routeMetric = uint32(m) + } + + if oneContext[ifaceName+"_METHOD"] == "dhcp" { + networkConfig.Operators = append(networkConfig.Operators, + network.OperatorSpecSpec{ + Operator: network.OperatorDHCP4, + LinkName: ifaceNameLower, + RequireUp: true, + DHCP4: network.DHCP4OperatorSpec{ + RouteMetric: routeMetric, + SkipHostnameRequest: true, + }, + ConfigLayer: network.ConfigPlatform, + }, + ) + + return nil + } + + return parseIPv4StaticConfig(oneContext, ifaceName, ifaceNameLower, routeMetric, networkConfig, allDNSIPs, allSearchDomains) +} + +// ip6PrefixFrom builds a netip.Prefix from an IPv6 address string and an +// optional prefix-length string (default 64). The prefix is not masked so the +// full host address is preserved on the interface. +func ip6PrefixFrom(ipStr, prefixLenStr string) (netip.Prefix, error) { + ip, err := netip.ParseAddr(ipStr) + if err != nil { + return netip.Prefix{}, fmt.Errorf("failed to parse IPv6 address %q: %w", ipStr, err) + } + + bits := 64 + + if prefixLenStr != "" { + n, err := strconv.Atoi(prefixLenStr) + if err != nil { + return netip.Prefix{}, fmt.Errorf("failed to parse IPv6 prefix length %q: %w", prefixLenStr, err) + } + + bits = n + } + + return netip.PrefixFrom(ip, bits), nil +} + +// parseIPv6Gateway reads ETH*_IP6_GATEWAY (or legacy GATEWAY6) and emits the +// default IPv6 route (::/0) with metric from ETH*_IP6_METRIC (default 1). +func parseIPv6Gateway(oneContext map[string]string, ifaceName, ifaceNameLower string, networkConfig *runtime.PlatformNetworkConfig) error { + gwStr := oneContext[ifaceName+"_IP6_GATEWAY"] + if gwStr == "" { + gwStr = oneContext[ifaceName+"_GATEWAY6"] + } + + if gwStr == "" { + return nil + } + + gw, err := netip.ParseAddr(gwStr) + if err != nil { + return fmt.Errorf("interface %s: failed to parse IPv6 gateway %q: %w", ifaceName, gwStr, err) + } + + metric := uint32(1) + + if metricStr := oneContext[ifaceName+"_IP6_METRIC"]; metricStr != "" { + m, err := strconv.ParseUint(metricStr, 10, 32) + if err != nil { + return fmt.Errorf("interface %s: failed to parse IPv6 metric: %w", ifaceName, err) + } + + metric = uint32(m) + } + + route := network.RouteSpecSpec{ + ConfigLayer: network.ConfigPlatform, + Gateway: gw, + OutLinkName: ifaceNameLower, + Table: nethelpers.TableMain, + Protocol: nethelpers.ProtocolStatic, + Type: nethelpers.TypeUnicast, + Family: nethelpers.FamilyInet6, + Priority: metric, + } + + route.Normalize() + + networkConfig.Routes = append(networkConfig.Routes, route) + + return nil +} + +// parseInterfaceIPv6 configures the static IPv6 stack for one interface. +// Handles ETH*_IP6 (legacy: ETH*_IPV6), ETH*_IP6_PREFIX_LENGTH, ETH*_IP6_ULA, +// ETH*_IP6_GATEWAY (legacy: ETH*_GATEWAY6), and ETH*_IP6_METRIC. +func parseInterfaceIPv6(oneContext map[string]string, ifaceName, ifaceNameLower string, networkConfig *runtime.PlatformNetworkConfig) error { + ip6Str := oneContext[ifaceName+"_IP6"] + if ip6Str == "" { + ip6Str = oneContext[ifaceName+"_IPV6"] + } + + prefixLenStr := oneContext[ifaceName+"_IP6_PREFIX_LENGTH"] + + if ip6Str != "" { + ip6Prefix, err := ip6PrefixFrom(ip6Str, prefixLenStr) + if err != nil { + return fmt.Errorf("interface %s: %w", ifaceName, err) + } + + networkConfig.Addresses = append(networkConfig.Addresses, network.AddressSpecSpec{ + Address: ip6Prefix, + LinkName: ifaceNameLower, + Family: nethelpers.FamilyInet6, + Scope: nethelpers.ScopeGlobal, + Flags: nethelpers.AddressFlags(nethelpers.AddressPermanent), + ConfigLayer: network.ConfigPlatform, + }) + } + + if ulaStr := oneContext[ifaceName+"_IP6_ULA"]; ulaStr != "" { + ulaPrefix, err := ip6PrefixFrom(ulaStr, "64") + if err != nil { + return fmt.Errorf("interface %s ULA: %w", ifaceName, err) + } + + networkConfig.Addresses = append(networkConfig.Addresses, network.AddressSpecSpec{ + Address: ulaPrefix, + LinkName: ifaceNameLower, + Family: nethelpers.FamilyInet6, + Scope: nethelpers.ScopeGlobal, + Flags: nethelpers.AddressFlags(nethelpers.AddressPermanent), + ConfigLayer: network.ConfigPlatform, + }) + } + + return parseIPv6Gateway(oneContext, ifaceName, ifaceNameLower, networkConfig) +} + +// parseInterface runs all per-interface configuration (IPv4, IPv6, aliases). +func parseInterface(oneContext map[string]string, ifaceName string, networkConfig *runtime.PlatformNetworkConfig, allDNSIPs *[]netip.Addr, allSearchDomains *[]string) error { + ifaceNameLower := strings.ToLower(ifaceName) + + if err := parseInterfaceIPv4(oneContext, ifaceName, ifaceNameLower, networkConfig, allDNSIPs, allSearchDomains); err != nil { + return err + } + + if err := parseInterfaceIPv6(oneContext, ifaceName, ifaceNameLower, networkConfig); err != nil { + return err + } + + aliasAddrs, err := parseAliases(oneContext, ifaceName, ifaceNameLower) + if err != nil { + return err + } + + networkConfig.Addresses = append(networkConfig.Addresses, aliasAddrs...) + + return nil +} + +// ethInterfaceName returns the interface name (e.g. "ETH0") from a context map +// key of the form ETH_MAC, or ("", false) for any other key. +func ethInterfaceName(key string) (string, bool) { + if !strings.HasPrefix(key, "ETH") || !strings.HasSuffix(key, "_MAC") { + return "", false + } + + name := strings.TrimSuffix(key, "_MAC") + + if !isDigitsOnly(strings.TrimPrefix(name, "ETH")) { + return "", false + } + + return name, true +} + +// resolveHostname picks the best hostname value from the context map and +// sanitizes it. Precedence: HOSTNAME > SET_HOSTNAME > NAME. +func resolveHostname(oneContext map[string]string) string { + // HOSTNAME is checked first (deviation from the reference which tries + // SET_HOSTNAME before HOSTNAME) to preserve backward compatibility with + // existing Talos deployments that rely on the OpenNebula-injected FQDN. + v := oneContext["HOSTNAME"] + if v == "" { + v = oneContext["SET_HOSTNAME"] + if v == "" { + v = oneContext["NAME"] + } + } + + return sanitizeHostname(v) +} + // ParseMetadata converts opennebula metadata to platform network config. -// -//nolint:gocyclo,cyclop func (o *OpenNebula) ParseMetadata(st state.State, oneContextPlain []byte) (*runtime.PlatformNetworkConfig, error) { - // Initialize the PlatformNetworkConfig networkConfig := &runtime.PlatformNetworkConfig{} oneContext, err := envparse.Parse(bytes.NewReader(oneContextPlain)) @@ -279,19 +572,7 @@ func (o *OpenNebula) ParseMetadata(st state.State, oneContextPlain []byte) (*run return nil, fmt.Errorf("failed to parse context file %q: %w", oneContextPlain, err) } - // Create HostnameSpecSpec entry - // HOSTNAME is checked first (deviation from the reference which tries - // SET_HOSTNAME before HOSTNAME) to preserve backward compatibility with - // existing Talos deployments that rely on the OpenNebula-injected FQDN. - hostnameValue := oneContext["HOSTNAME"] - if hostnameValue == "" { - hostnameValue = oneContext["SET_HOSTNAME"] - if hostnameValue == "" { - hostnameValue = oneContext["NAME"] - } - } - - hostnameValue = sanitizeHostname(hostnameValue) + hostnameValue := resolveHostname(oneContext) // Seed the merged DNS/search-domain slices with global variables (DNS, // SEARCH_DOMAIN). These are applied regardless of interface, matching the @@ -299,8 +580,6 @@ func (o *OpenNebula) ParseMetadata(st state.State, oneContextPlain []byte) (*run // variables before per-interface ones. var allDNSIPs []netip.Addr - var allSearchDomains []string - for s := range strings.FieldsSeq(oneContext["DNS"]) { ip, err := netip.ParseAddr(s) if err != nil { @@ -310,161 +589,24 @@ func (o *OpenNebula) ParseMetadata(st state.State, oneContextPlain []byte) (*run allDNSIPs = append(allDNSIPs, ip) } - allSearchDomains = append(allSearchDomains, strings.Fields(oneContext["SEARCH_DOMAIN"])...) + allSearchDomains := append([]string(nil), strings.Fields(oneContext["SEARCH_DOMAIN"])...) - // Iterate through parsed environment variables looking for ETHn_MAC keys. // The presence of ETHn_MAC is the sole trigger for interface configuration, // matching the behavior of the official OpenNebula guest contextualization // scripts (one-apps/context-linux: get_context_interfaces() uses ETH*_MAC - // presence exclusively). The NETWORK context variable is a server-side - // directive that tells OpenNebula to auto-inject ETH*_ variables from NIC - // definitions; it is not a guest-side signal and is never read by the - // official scripts. + // presence exclusively). for key := range oneContext { - if strings.HasPrefix(key, "ETH") && strings.HasSuffix(key, "_MAC") { - ifaceName := strings.TrimSuffix(key, "_MAC") - // Skip alias MAC keys (e.g. ETH0_ALIAS0_MAC); only process - // top-level interface keys of the form ETH_MAC, - // matching the reference get_context_interfaces() regex ETH[0-9]+. - if !isDigitsOnly(strings.TrimPrefix(ifaceName, "ETH")) { - continue - } + ifaceName, ok := ethInterfaceName(key) + if !ok { + continue + } - ifaceNameLower := strings.ToLower(ifaceName) - - routeMetric := uint32(network.DefaultRouteMetric) - - if metricStr := oneContext[ifaceName+"_METRIC"]; metricStr != "" { - m, err := strconv.ParseUint(metricStr, 10, 32) - if err != nil { - return nil, fmt.Errorf("interface %s: failed to parse metric: %w", ifaceName, err) - } - - routeMetric = uint32(m) - } - - if oneContext[ifaceName+"_METHOD"] == "dhcp" { - // Create DHCP4 OperatorSpec entry - networkConfig.Operators = append(networkConfig.Operators, - network.OperatorSpecSpec{ - Operator: network.OperatorDHCP4, - LinkName: ifaceNameLower, - RequireUp: true, - DHCP4: network.DHCP4OperatorSpec{ - RouteMetric: routeMetric, - SkipHostnameRequest: true, - }, - ConfigLayer: network.ConfigPlatform, - }, - ) - } else { - // Parse IP address and create AddressSpecSpec entry - ipPrefix, err := address.IPPrefixFrom(oneContext[ifaceName+"_IP"], oneContext[ifaceName+"_MASK"]) - if err != nil { - return nil, fmt.Errorf("failed to parse IP address: %w", err) - } - - networkConfig.Addresses = append(networkConfig.Addresses, - network.AddressSpecSpec{ - Address: ipPrefix, - LinkName: ifaceNameLower, - Family: nethelpers.FamilyInet4, - Scope: nethelpers.ScopeGlobal, - Flags: nethelpers.AddressFlags(nethelpers.AddressPermanent), - AnnounceWithARP: false, - ConfigLayer: network.ConfigPlatform, - }, - ) - - var mtu uint32 - - if oneContext[ifaceName+"_MTU"] == "" { - mtu = 0 - } else { - var mtu64 uint64 - - mtu64, err = strconv.ParseUint(oneContext[ifaceName+"_MTU"], 10, 32) - // check if any error happened - if err != nil { - return nil, fmt.Errorf("failed to parse MTU: %w", err) - } - - mtu = uint32(mtu64) - } - - // Create LinkSpecSpec entry - networkConfig.Links = append(networkConfig.Links, - network.LinkSpecSpec{ - Name: ifaceNameLower, - Logical: false, - Up: true, - MTU: mtu, - Kind: "", - Type: nethelpers.LinkEther, - ParentName: "", - ConfigLayer: network.ConfigPlatform, - }, - ) - - if oneContext[ifaceName+"_GATEWAY"] != "" { - // Parse gateway address and create RouteSpecSpec entry - gateway, err := netip.ParseAddr(oneContext[ifaceName+"_GATEWAY"]) - if err != nil { - return nil, fmt.Errorf("failed to parse gateway ip: %w", err) - } - - route := network.RouteSpecSpec{ - ConfigLayer: network.ConfigPlatform, - Gateway: gateway, - OutLinkName: ifaceNameLower, - Table: nethelpers.TableMain, - Protocol: nethelpers.ProtocolStatic, - Type: nethelpers.TypeUnicast, - Family: nethelpers.FamilyInet4, - Priority: routeMetric, - } - - route.Normalize() - - networkConfig.Routes = append(networkConfig.Routes, route) - } - - if routesStr := oneContext[ifaceName+"_ROUTES"]; routesStr != "" { - staticRoutes, err := ParseRoutes(routesStr, ifaceNameLower) - if err != nil { - return nil, fmt.Errorf("interface %s: %w", ifaceName, err) - } - - networkConfig.Routes = append(networkConfig.Routes, staticRoutes...) - } - - // Accumulate per-interface DNS servers and search domains into - // the shared slices (global values were seeded before the loop). - for s := range strings.FieldsSeq(oneContext[ifaceName+"_DNS"]) { - ip, err := netip.ParseAddr(s) - if err != nil { - return nil, fmt.Errorf("interface %s: failed to parse DNS server %q: %w", ifaceName, s, err) - } - - allDNSIPs = append(allDNSIPs, ip) - } - - allSearchDomains = append(allSearchDomains, strings.Fields(oneContext[ifaceName+"_SEARCH_DOMAIN"])...) - } - - // Process alias addresses for this interface (applies to both - // static and DHCP interfaces, matching the reference behavior). - aliasAddrs, err := parseAliases(oneContext, ifaceName, ifaceNameLower) - if err != nil { - return nil, err - } - - networkConfig.Addresses = append(networkConfig.Addresses, aliasAddrs...) + if err := parseInterface(oneContext, ifaceName, networkConfig, &allDNSIPs, &allSearchDomains); err != nil { + return nil, err } } - // Emit a single merged ResolverSpecSpec combining global and per-interface - // values, matching the reference single /etc/resolv.conf output. - if len(allDNSIPs) > 0 || len(allSearchDomains) > 0 { + + if len(allDNSIPs)+len(allSearchDomains) > 0 { networkConfig.Resolvers = append(networkConfig.Resolvers, network.ResolverSpecSpec{ DNSServers: allDNSIPs, SearchDomains: allSearchDomains,