feat(machined): add static routes support via ETH*_ROUTES for OpenNebula

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 <mickael.canevet@proton.ch>
Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
This commit is contained in:
Mickaël Canévet 2026-03-09 14:39:55 +01:00 committed by Andrey Smirnov
parent ad3c59aada
commit 23c99a3cb4
No known key found for this signature in database
GPG Key ID: 322C6F63F594CE7C
6 changed files with 396 additions and 1 deletions

View File

@ -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"])

View File

@ -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)
})
}
}

View File

@ -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: ""

View File

@ -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: ""

View File

@ -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 = ""

View File

@ -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 = ""