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 index 43bc713b0..8b5120ddf 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/ipv6_test.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/ipv6_test.go @@ -144,6 +144,47 @@ func TestParseIPv6(t *testing.T) { extra: "ETH0_IP6 = \"2001:db8::1\"", wantAddrs: []netip.Prefix{netip.MustParsePrefix("2001:db8::1/64")}, }, + { + name: "METHOD=dhcp with no IP6_METHOD inherits dhcp and emits OperatorDHCP6", + extra: "ETH0_METHOD = \"dhcp\"", + wantOperators: []network.OperatorSpecSpec{dhcp6Op(1)}, + }, + { + name: "METHOD=dhcp with IP6_METHOD=static and IP6 set uses static IPv6", + extra: "ETH0_METHOD = \"dhcp\"\nETH0_IP6_METHOD = \"static\"\nETH0_IP6 = \"2001:db8::1\"", + wantAddrs: []netip.Prefix{netip.MustParsePrefix("2001:db8::1/64")}, + }, + { + name: "METHOD=dhcp with IP6_METHOD=disable emits no IPv6 config", + extra: "ETH0_METHOD = \"dhcp\"\nETH0_IP6_METHOD = \"disable\"", + }, + { + name: "METHOD=static with no IP6_METHOD and no IP6 emits no IPv6 config", + extra: "ETH0_METHOD = \"static\"", + }, + { + name: "METRIC=200 with no IP6_METRIC cascades to IPv6 gateway metric", + extra: "ETH0_IP6 = \"2001:db8::1\"\nETH0_IP6_GATEWAY = \"2001:db8::fffe\"\nETH0_METRIC = \"200\"", + wantAddrs: []netip.Prefix{netip.MustParsePrefix("2001:db8::1/64")}, + wantRoutes: []network.RouteSpecSpec{gw6Route("2001:db8::fffe", 200)}, + }, + { + name: "METRIC=200 with IP6_METRIC=50 uses explicit IP6_METRIC", + extra: "ETH0_IP6 = \"2001:db8::1\"\nETH0_IP6_GATEWAY = \"2001:db8::fffe\"\nETH0_METRIC = \"200\"\nETH0_IP6_METRIC = \"50\"", + wantAddrs: []netip.Prefix{netip.MustParsePrefix("2001:db8::1/64")}, + wantRoutes: []network.RouteSpecSpec{gw6Route("2001:db8::fffe", 50)}, + }, + { + name: "METRIC=200 with IP6_METHOD=dhcp and no IP6_METRIC cascades to DHCPv6 metric", + extra: "ETH0_IP6_METHOD = \"dhcp\"\nETH0_METRIC = \"200\"", + wantOperators: []network.OperatorSpecSpec{dhcp6Op(200)}, + }, + { + name: "no METRIC and no IP6_METRIC uses IPv6 default of 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{gw6Route("2001:db8::fffe", 1)}, + }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() 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 e488fcecd..9eab19568 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/opennebula.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/opennebula.go @@ -413,22 +413,53 @@ func parseIPv4StaticConfig( return nil } +// parseIPv4Metric reads ETH*_METRIC and returns the parsed value, or 0 when +// the variable is absent. Callers apply their own default (e.g. +// network.DefaultRouteMetric for IPv4, 1 for IPv6 via parseIPv6Metric). +func parseIPv4Metric(oneContext map[string]string, ifaceName string) (uint32, error) { + if metricStr := oneContext[ifaceName+"_METRIC"]; metricStr != "" { + m, err := strconv.ParseUint(metricStr, 10, 32) + if err != nil { + return 0, fmt.Errorf("interface %s: failed to parse metric: %w", ifaceName, err) + } + + return uint32(m), nil + } + + return 0, nil +} + +// parseIPv6Metric reads ETH*_IP6_METRIC; falls back to ipv4Metric (when > 0), +// then to 1, matching the reference [ -z "$ip6_metric" ] && ip6_metric="${metric}". +func parseIPv6Metric(oneContext map[string]string, ifaceName string, ipv4Metric uint32) (uint32, error) { + if metricStr := oneContext[ifaceName+"_IP6_METRIC"]; metricStr != "" { + m, err := strconv.ParseUint(metricStr, 10, 32) + if err != nil { + return 0, fmt.Errorf("interface %s: failed to parse IPv6 metric: %w", ifaceName, err) + } + + return uint32(m), nil + } + + if ipv4Metric > 0 { + return ipv4Metric, nil + } + + return 1, 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 { +func parseInterfaceIPv4( + oneContext map[string]string, ifaceName, ifaceNameLower string, routeMetric uint32, + networkConfig *runtime.PlatformNetworkConfig, allDNSIPs *[]netip.Addr, allSearchDomains *[]string, +) error { if oneContext[ifaceName+"_METHOD"] == methodSkip { return nil } - 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 routeMetric == 0 { + routeMetric = uint32(network.DefaultRouteMetric) } if oneContext[ifaceName+"_METHOD"] == "dhcp" { @@ -475,8 +506,8 @@ func ip6PrefixFrom(ipStr, prefixLenStr string) (netip.Prefix, error) { } // 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 { +// default IPv6 route (::/0) with metric from parseIPv6Metric. +func parseIPv6Gateway(oneContext map[string]string, ifaceName, ifaceNameLower string, ipv4Metric uint32, networkConfig *runtime.PlatformNetworkConfig) error { gwStr := oneContext[ifaceName+"_IP6_GATEWAY"] if gwStr == "" { gwStr = oneContext[ifaceName+"_GATEWAY6"] @@ -491,15 +522,9 @@ func parseIPv6Gateway(oneContext map[string]string, ifaceName, ifaceNameLower st 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) + metric, err := parseIPv6Metric(oneContext, ifaceName, ipv4Metric) + if err != nil { + return err } route := network.RouteSpecSpec{ @@ -521,17 +546,11 @@ func parseIPv6Gateway(oneContext map[string]string, ifaceName, ifaceNameLower st } // parseIPv6DHCP emits a DHCPv6 operator for an interface, with metric from -// ETH*_IP6_METRIC (default 1). -func parseIPv6DHCP(oneContext map[string]string, ifaceName, ifaceNameLower string, networkConfig *runtime.PlatformNetworkConfig) error { - 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) +// parseIPv6Metric. +func parseIPv6DHCP(oneContext map[string]string, ifaceName, ifaceNameLower string, ipv4Metric uint32, networkConfig *runtime.PlatformNetworkConfig) error { + metric, err := parseIPv6Metric(oneContext, ifaceName, ipv4Metric) + if err != nil { + return err } networkConfig.Operators = append(networkConfig.Operators, network.OperatorSpecSpec{ @@ -549,10 +568,17 @@ func parseIPv6DHCP(oneContext map[string]string, ifaceName, ifaceNameLower strin } // parseInterfaceIPv6 configures the IPv6 stack for one interface. -// Dispatches on ETH*_IP6_METHOD: disable (skip), auto (SLAAC via kernel), -// dhcp (DHCPv6 operator), or static/empty (Phase 2 static path). -func parseInterfaceIPv6(oneContext map[string]string, ifaceName, ifaceNameLower string, networkConfig *runtime.PlatformNetworkConfig) error { - switch strings.ToLower(oneContext[ifaceName+"_IP6_METHOD"]) { +// Dispatches on the effective IP6_METHOD: disable/skip (no-op), auto (SLAAC), +// dhcp (DHCPv6 operator), or static/empty (static address path). +// When IP6_METHOD is unset, ipv4Method is used as fallback, matching the +// reference: [ -z "$ip6_method" ] && ip6_method="${method}". +func parseInterfaceIPv6(oneContext map[string]string, ifaceName, ifaceNameLower string, ipv4Method string, ipv4Metric uint32, networkConfig *runtime.PlatformNetworkConfig) error { + ip6Method := strings.ToLower(oneContext[ifaceName+"_IP6_METHOD"]) + if ip6Method == "" { + ip6Method = ipv4Method + } + + switch ip6Method { case "disable", methodSkip: return nil case "auto": @@ -560,7 +586,7 @@ func parseInterfaceIPv6(oneContext map[string]string, ifaceName, ifaceNameLower // no operator or sysctl is required to enable address auto-configuration. return nil case "dhcp": - return parseIPv6DHCP(oneContext, ifaceName, ifaceNameLower, networkConfig) + return parseIPv6DHCP(oneContext, ifaceName, ifaceNameLower, ipv4Metric, networkConfig) } ip6Str := oneContext[ifaceName+"_IP6"] @@ -602,23 +628,33 @@ func parseInterfaceIPv6(oneContext map[string]string, ifaceName, ifaceNameLower }) } - return parseIPv6Gateway(oneContext, ifaceName, ifaceNameLower, networkConfig) + return parseIPv6Gateway(oneContext, ifaceName, ifaceNameLower, ipv4Metric, 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) + ipv4Method := strings.ToLower(oneContext[ifaceName+"_METHOD"]) ip6Method := strings.ToLower(oneContext[ifaceName+"_IP6_METHOD"]) - if oneContext[ifaceName+"_METHOD"] == methodSkip && (ip6Method == "" || ip6Method == "disable" || ip6Method == methodSkip) { + if ip6Method == "" { + ip6Method = ipv4Method + } + + if ipv4Method == methodSkip && (ip6Method == "" || ip6Method == methodSkip || ip6Method == "disable") { return nil } - if err := parseInterfaceIPv4(oneContext, ifaceName, ifaceNameLower, networkConfig, allDNSIPs, allSearchDomains); err != nil { + ipv4Metric, err := parseIPv4Metric(oneContext, ifaceName) + if err != nil { return err } - if err := parseInterfaceIPv6(oneContext, ifaceName, ifaceNameLower, networkConfig); err != nil { + if err := parseInterfaceIPv4(oneContext, ifaceName, ifaceNameLower, ipv4Metric, networkConfig, allDNSIPs, allSearchDomains); err != nil { + return err + } + + if err := parseInterfaceIPv6(oneContext, ifaceName, ifaceNameLower, ipv4Method, ipv4Metric, networkConfig); err != nil { return err } diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/expected.yaml b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/expected.yaml index 8dd8efe63..6cb4168fe 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/expected.yaml +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/expected.yaml @@ -79,6 +79,13 @@ operators: routeMetric: 200 skipHostnameRequest: true layer: platform + - operator: dhcp6 + linkName: eth1 + requireUp: true + dhcp6: + routeMetric: 200 + skipHostnameRequest: true + layer: platform externalIPs: [] metadata: platform: opennebula diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/expected_no_network_flag.yaml b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/expected_no_network_flag.yaml index 8dd8efe63..6cb4168fe 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/expected_no_network_flag.yaml +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/expected_no_network_flag.yaml @@ -79,6 +79,13 @@ operators: routeMetric: 200 skipHostnameRequest: true layer: platform + - operator: dhcp6 + linkName: eth1 + requireUp: true + dhcp6: + routeMetric: 200 + skipHostnameRequest: true + layer: platform externalIPs: [] metadata: platform: opennebula