From 23c99a3cb44b0d9c7aa9592700a8a9f3e2b097f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Can=C3=A9vet?= Date: Mon, 9 Mar 2026 14:39:55 +0100 Subject: [PATCH] feat(machined): add static routes support via ETH*_ROUTES for OpenNebula MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse the ETH*_ROUTES context variable in the OpenNebula platform and install per-interface static routes into the platform network config. Both legacy format ("DEST MASK GW [METRIC]") and CIDR format ("DEST/PREFIX GW [METRIC]") are supported, matching the reference one-apps context-linux implementation. Signed-off-by: Mickaël Canévet Signed-off-by: Andrey Smirnov --- .../platform/opennebula/opennebula.go | 128 +++++++++- .../platform/opennebula/routes_test.go | 219 ++++++++++++++++++ .../opennebula/testdata/expected.yaml | 24 ++ .../testdata/expected_no_network_flag.yaml | 24 ++ .../opennebula/testdata/metadata.yaml | 1 + .../testdata/metadata_no_network_flag.yaml | 1 + 6 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/routes_test.go 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 f42f73819..82945b62f 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/opennebula.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/opennebula.go @@ -36,9 +36,126 @@ func (o *OpenNebula) Name() string { return "opennebula" } +// parseRouteFields extracts the destination prefix, gateway string, and optional +// metric string from the fields of a single route entry. +// +// The reference one-apps implementation (context-linux) always parses routes as: +// +// rsplit=( ${route} ); dst="${rsplit[0]}"; gw="${rsplit[2]}" +// +// meaning token[1] is always skipped and the gateway is always at token[2]. +// The canonical format is "DEST/PREFIX via GW" where "via" occupies token[1]. +// The legacy dotted-mask format "DEST MASK GW" follows the same index layout. +// +// As a Talos extension, an optional bare metric may follow the gateway. +func parseRouteFields(parts []string) (dest netip.Prefix, gwStr, metricStr string, err error) { + // Both CIDR ("DEST/PREFIX via GW") and legacy ("DEST MASK GW") formats + // require at least 3 tokens, with the gateway always at index 2. + if len(parts) < 3 { + return dest, "", "", fmt.Errorf("expected at least 3 fields (DEST/PREFIX via GW or DEST MASK GW)") + } + + if strings.Contains(parts[0], "/") { + // CIDR format: "DEST/PREFIX via GW [METRIC]" + // parts[1] is the separator token (conventionally "via") and is skipped, + // matching the reference rsplit[1] which is never read. + dest, err = netip.ParsePrefix(parts[0]) + if err != nil { + return dest, "", "", fmt.Errorf("failed to parse destination: %w", err) + } + + dest = dest.Masked() + } else { + // Legacy format: "DEST MASK GW [METRIC]" + var prefix netip.Prefix + + prefix, err = address.IPPrefixFrom(parts[0], parts[1]) + if err != nil { + return dest, "", "", fmt.Errorf("failed to parse destination: %w", err) + } + + dest = prefix.Masked() + } + + gwStr = parts[2] + + if len(parts) >= 4 { + metricStr = parts[3] + } + + return dest, gwStr, metricStr, nil +} + +// parseRouteEntry parses a single trimmed route entry into a RouteSpecSpec. +func parseRouteEntry(entry, linkName string) (network.RouteSpecSpec, error) { + dest, gwStr, metricStr, err := parseRouteFields(strings.Fields(entry)) + if err != nil { + return network.RouteSpecSpec{}, fmt.Errorf("route entry %q: %w", entry, err) + } + + gw, err := netip.ParseAddr(gwStr) + if err != nil { + return network.RouteSpecSpec{}, fmt.Errorf("route entry %q: failed to parse gateway: %w", entry, err) + } + + metric := uint32(network.DefaultRouteMetric) + + if metricStr != "" { + m, err := strconv.ParseUint(metricStr, 10, 32) + if err != nil { + return network.RouteSpecSpec{}, fmt.Errorf("route entry %q: failed to parse metric: %w", entry, err) + } + + metric = uint32(m) + } + + family := nethelpers.FamilyInet4 + if gw.Is6() { + family = nethelpers.FamilyInet6 + } + + route := network.RouteSpecSpec{ + ConfigLayer: network.ConfigPlatform, + Destination: dest, + Gateway: gw, + OutLinkName: linkName, + Table: nethelpers.TableMain, + Protocol: nethelpers.ProtocolStatic, + Type: nethelpers.TypeUnicast, + Family: family, + Priority: metric, + } + + route.Normalize() + + return route, nil +} + +// ParseRoutes parses the ETH*_ROUTES variable into RouteSpecSpec entries. +// Multiple routes are separated by commas. +func ParseRoutes(routesStr, linkName string) ([]network.RouteSpecSpec, error) { + var routes []network.RouteSpecSpec + + for entry := range strings.SplitSeq(routesStr, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + + route, err := parseRouteEntry(entry, linkName) + if err != nil { + return nil, err + } + + routes = append(routes, route) + } + + return routes, nil +} + // ParseMetadata converts opennebula metadata to platform network config. // -//nolint:gocyclo +//nolint:gocyclo,cyclop func (o *OpenNebula) ParseMetadata(st state.State, oneContextPlain []byte) (*runtime.PlatformNetworkConfig, error) { // Initialize the PlatformNetworkConfig networkConfig := &runtime.PlatformNetworkConfig{} @@ -156,6 +273,15 @@ func (o *OpenNebula) ParseMetadata(st state.State, oneContextPlain []byte) (*run 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...) + } + // Parse DNS servers dnsServers := strings.Fields(oneContext[ifaceName+"_DNS"]) diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/routes_test.go b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/routes_test.go new file mode 100644 index 000000000..9d304a087 --- /dev/null +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/routes_test.go @@ -0,0 +1,219 @@ +// 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/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" +) + +func TestParseRoutes(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + routesStr string + linkName string + expected []network.RouteSpecSpec + errMsg string + }{ + { + name: "empty string", + routesStr: "", + linkName: "eth0", + expected: nil, + }, + { + name: "whitespace only", + routesStr: " , , ", + linkName: "eth0", + expected: nil, + }, + { + name: "legacy single route default metric", + routesStr: "10.0.0.0 255.0.0.0 192.168.1.1", + linkName: "eth0", + expected: []network.RouteSpecSpec{ + { + ConfigLayer: network.ConfigPlatform, + Destination: netip.MustParsePrefix("10.0.0.0/8"), + Gateway: netip.MustParseAddr("192.168.1.1"), + OutLinkName: "eth0", + Table: nethelpers.TableMain, + Protocol: nethelpers.ProtocolStatic, + Type: nethelpers.TypeUnicast, + Family: nethelpers.FamilyInet4, + Priority: network.DefaultRouteMetric, + Scope: nethelpers.ScopeGlobal, + }, + }, + }, + { + name: "legacy single route custom metric", + routesStr: "172.16.0.0 255.255.0.0 192.168.1.1 500", + linkName: "eth0", + expected: []network.RouteSpecSpec{ + { + ConfigLayer: network.ConfigPlatform, + Destination: netip.MustParsePrefix("172.16.0.0/16"), + Gateway: netip.MustParseAddr("192.168.1.1"), + OutLinkName: "eth0", + Table: nethelpers.TableMain, + Protocol: nethelpers.ProtocolStatic, + Type: nethelpers.TypeUnicast, + Family: nethelpers.FamilyInet4, + Priority: 500, + Scope: nethelpers.ScopeGlobal, + }, + }, + }, + { + name: "cidr single route", + routesStr: "10.0.0.0/8 via 192.168.1.1", + linkName: "eth0", + expected: []network.RouteSpecSpec{ + { + ConfigLayer: network.ConfigPlatform, + Destination: netip.MustParsePrefix("10.0.0.0/8"), + Gateway: netip.MustParseAddr("192.168.1.1"), + OutLinkName: "eth0", + Table: nethelpers.TableMain, + Protocol: nethelpers.ProtocolStatic, + Type: nethelpers.TypeUnicast, + Family: nethelpers.FamilyInet4, + Priority: network.DefaultRouteMetric, + Scope: nethelpers.ScopeGlobal, + }, + }, + }, + { + name: "cidr single route with metric", + routesStr: "10.0.0.0/8 via 192.168.1.1 200", + linkName: "eth0", + expected: []network.RouteSpecSpec{ + { + ConfigLayer: network.ConfigPlatform, + Destination: netip.MustParsePrefix("10.0.0.0/8"), + Gateway: netip.MustParseAddr("192.168.1.1"), + OutLinkName: "eth0", + Table: nethelpers.TableMain, + Protocol: nethelpers.ProtocolStatic, + Type: nethelpers.TypeUnicast, + Family: nethelpers.FamilyInet4, + Priority: 200, + Scope: nethelpers.ScopeGlobal, + }, + }, + }, + { + name: "multiple routes comma separated", + routesStr: "10.0.0.0 255.0.0.0 192.168.1.1, 172.16.0.0 255.255.0.0 192.168.1.1 500", + linkName: "eth0", + expected: []network.RouteSpecSpec{ + { + ConfigLayer: network.ConfigPlatform, + Destination: netip.MustParsePrefix("10.0.0.0/8"), + Gateway: netip.MustParseAddr("192.168.1.1"), + OutLinkName: "eth0", + Table: nethelpers.TableMain, + Protocol: nethelpers.ProtocolStatic, + Type: nethelpers.TypeUnicast, + Family: nethelpers.FamilyInet4, + Priority: network.DefaultRouteMetric, + Scope: nethelpers.ScopeGlobal, + }, + { + ConfigLayer: network.ConfigPlatform, + Destination: netip.MustParsePrefix("172.16.0.0/16"), + Gateway: netip.MustParseAddr("192.168.1.1"), + OutLinkName: "eth0", + Table: nethelpers.TableMain, + Protocol: nethelpers.ProtocolStatic, + Type: nethelpers.TypeUnicast, + Family: nethelpers.FamilyInet4, + Priority: 500, + Scope: nethelpers.ScopeGlobal, + }, + }, + }, + { + name: "cidr host bits masked", + routesStr: "10.1.2.0/8 via 192.168.1.1", + linkName: "eth0", + expected: []network.RouteSpecSpec{ + { + ConfigLayer: network.ConfigPlatform, + Destination: netip.MustParsePrefix("10.0.0.0/8"), + Gateway: netip.MustParseAddr("192.168.1.1"), + OutLinkName: "eth0", + Table: nethelpers.TableMain, + Protocol: nethelpers.ProtocolStatic, + Type: nethelpers.TypeUnicast, + Family: nethelpers.FamilyInet4, + Priority: network.DefaultRouteMetric, + Scope: nethelpers.ScopeGlobal, + }, + }, + }, + { + name: "malformed gateway", + routesStr: "10.0.0.0/8 via notanip", + linkName: "eth0", + errMsg: "failed to parse gateway", + }, + { + name: "malformed cidr destination", + routesStr: "notaprefix/8 via 192.168.1.1", + linkName: "eth0", + errMsg: "failed to parse destination", + }, + { + name: "malformed legacy destination", + routesStr: "notanip 255.0.0.0 192.168.1.1", + linkName: "eth0", + errMsg: "failed to parse destination", + }, + { + name: "malformed metric", + routesStr: "10.0.0.0/8 via 192.168.1.1 notanumber", + linkName: "eth0", + errMsg: "failed to parse metric", + }, + { + name: "too few fields", + routesStr: "10.0.0.0/8 via", + linkName: "eth0", + errMsg: "expected at least 3 fields", + }, + { + name: "legacy too few fields", + routesStr: "10.0.0.0 255.0.0.0", + linkName: "eth0", + errMsg: "expected at least 3 fields", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + routes, err := opennebula.ParseRoutes(tc.routesStr, tc.linkName) + + if tc.errMsg != "" { + require.ErrorContains(t, err, tc.errMsg) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expected, routes) + }) + } +} 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 0151c6309..f461bfb7e 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 @@ -26,6 +26,30 @@ routes: flags: "" protocol: static layer: platform + - family: inet4 + dst: 10.0.0.0/8 + src: "" + gateway: 192.168.1.1 + outLinkName: eth0 + table: main + priority: 1024 + scope: global + type: unicast + flags: "" + protocol: static + layer: platform + - family: inet4 + dst: 172.16.0.0/16 + src: "" + gateway: 192.168.1.1 + outLinkName: eth0 + table: main + priority: 500 + scope: global + type: unicast + flags: "" + protocol: static + layer: platform hostnames: - hostname: code-server domainname: "" 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 0151c6309..f461bfb7e 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 @@ -26,6 +26,30 @@ routes: flags: "" protocol: static layer: platform + - family: inet4 + dst: 10.0.0.0/8 + src: "" + gateway: 192.168.1.1 + outLinkName: eth0 + table: main + priority: 1024 + scope: global + type: unicast + flags: "" + protocol: static + layer: platform + - family: inet4 + dst: 172.16.0.0/16 + src: "" + gateway: 192.168.1.1 + outLinkName: eth0 + table: main + priority: 500 + scope: global + type: unicast + flags: "" + protocol: static + layer: platform hostnames: - hostname: code-server domainname: "" diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/metadata.yaml b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/metadata.yaml index cae9d345d..3bc6370ff 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/metadata.yaml +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/metadata.yaml @@ -16,6 +16,7 @@ ETH0_METHOD = "" ETH0_METRIC = "" ETH0_MTU = "" ETH0_NETWORK = "192.168.1.0" +ETH0_ROUTES = "10.0.0.0 255.0.0.0 192.168.1.1, 172.16.0.0 255.255.0.0 192.168.1.1 500" ETH0_SEARCH_DOMAIN = "" ETH0_VLAN_ID = "3" ETH0_VROUTER_IP = "" diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/metadata_no_network_flag.yaml b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/metadata_no_network_flag.yaml index 35613f926..9e67e2e19 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/metadata_no_network_flag.yaml +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/opennebula/testdata/metadata_no_network_flag.yaml @@ -18,6 +18,7 @@ ETH0_METHOD = "" ETH0_METRIC = "" ETH0_MTU = "" ETH0_NETWORK = "192.168.1.0" +ETH0_ROUTES = "10.0.0.0 255.0.0.0 192.168.1.1, 172.16.0.0 255.255.0.0 192.168.1.1 500" ETH0_SEARCH_DOMAIN = "" ETH0_VLAN_ID = "3" ETH0_VROUTER_IP = ""