Sitelet https://github.com/kubernetes/kops/pull/18642/files
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion nodeup/pkg/model/networking/amazon-vpc-routed-eni.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ func (b *AmazonVPCRoutedENIBuilder) Build(c *fi.NodeupModelBuilderContext) error
maskEC2NetUtilsUdevRules(c, b.Distribution)
disableManageForeignRoutes(c, b.Distribution)
setMACAddressPolicyNone(c, b.Distribution)
markSecondaryENIsUnmanaged(c, b.Distribution)
if err := markSecondaryENIsUnmanaged(c, b.Distribution); err != nil {
return err
}
disableCloudInitNetworkHotplug(c, b.Distribution)
narrowCloudIfupdownHelperRule(c, b.Distribution)
disableNMCloudSetup(c, b.Distribution)
Expand Down
4 changes: 3 additions & 1 deletion nodeup/pkg/model/networking/cilium.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ func (b *CiliumBuilder) Build(c *fi.NodeupModelBuilderContext) error {
if b.NodeupConfig.Networking.Cilium.IPAM == kops.CiliumIpamEni {
maskEC2NetUtilsUdevRules(c, b.Distribution)
setMACAddressPolicyNone(c, b.Distribution)
markSecondaryENIsUnmanaged(c, b.Distribution)
if err := markSecondaryENIsUnmanaged(c, b.Distribution); err != nil {
return err
}
}

return nil
Expand Down
111 changes: 102 additions & 9 deletions nodeup/pkg/model/networking/eni_networking.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ limitations under the License.
package networking

import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"

awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"

"k8s.io/kops/upup/pkg/fi"
"k8s.io/kops/upup/pkg/fi/nodeup/nodetasks"
"k8s.io/kops/util/pkg/distributions"
Expand Down Expand Up @@ -118,30 +128,113 @@ MACAddressPolicy=none
})
}

// markSecondaryENIsUnmanaged tells systemd-networkd to ignore secondary ENIs (ens6+).
// Without this, systemd-networkd fully manages secondary ENIs via DHCP, creating
// competing routes that interfere with CNI networking.
// markSecondaryENIsUnmanaged causes systemd-networkd to ignore the secondary ENIs that use the
// "ena" driver. Without this file, systemd-networkd starts DHCP on these ENIs and makes routes
// that do not agree with the CNI routing.
// AL2023 and Debian 12+.
// ref: https://github.com/aws/amazon-vpc-cni-k8s/issues/3524
func markSecondaryENIsUnmanaged(c *fi.NodeupModelBuilderContext, dist distributions.Distribution) {
//
// It is not possible to find the secondary ENIs by their names. The names change with the
// instance family and the systemd naming scheme. Examples: the primary network interface is
// "ens5" on most Nitro instances, "ens34" on Graviton4 instances (c8g etc.), and "enp39s0" on
// 8th-generation Intel instances (c8i etc.). Thus the file finds all the interfaces that use
// the "ena" driver, but does not include the primary network interface, which nodeup finds at
// boot time. The file uses the udev property "INTERFACE" for this, because a negated "Name="
// test also agrees with the alternative names of an interface.
//
// The file name starts with 75. This puts the file after the per-interface files
// ("10-netplan-*" on Debian, "70-*" on AL2023) and before the AL2023 catch-all file
// "80-ec2.network". systemd-networkd uses the first file that agrees with an interface. Thus,
// if the primary network interface has a per-interface file, systemd-networkd uses that file.
func markSecondaryENIsUnmanaged(c *fi.NodeupModelBuilderContext, dist distributions.Distribution) error {
if !(dist == distributions.DistributionAmazonLinux2023 ||
(dist.IsDebian() && dist.Version() >= 12)) {
return
return nil
}

contents := `
primary, err := primaryInterfaceName(c.Context())
if err != nil {
// Do not make the file if the primary network interface is not known. A match that
// includes the primary network interface causes systemd-networkd to ignore it, and
// then systemd-resolved has no DNS servers for it.
return fmt.Errorf("finding primary network interface: %w", err)
}

contents := fmt.Sprintf(`
[Match]
Name=ens[6-9]* ens[1-9][0-9]*
Driver=ena
Property=!INTERFACE=%s

[Link]
Unmanaged=yes
`
`, primary)

c.AddTask(&nodetasks.File{
Path: "/etc/systemd/network/10-eni-secondary.network",
Path: "/etc/systemd/network/75-eni-secondary.network",
Contents: fi.NewStringResource(contents),
Type: nodetasks.FileType_File,
OnChangeExecute: [][]string{{"systemctl", "restart", "systemd-networkd"}},
})
return nil
}

// primaryInterfaceName gives the name of the primary network interface. It gets the MAC address
// of the primary ENI (device-number 0) from the IMDS item "mac". Then it compares this MAC
// address with the physical network interfaces in sysfs.
func primaryInterfaceName(ctx context.Context) (string, error) {
config, err := awsconfig.LoadDefaultConfig(ctx)
if err != nil {
return "", fmt.Errorf("loading AWS config: %w", err)
}
metadata := imds.NewFromConfig(config)
resp, err := metadata.GetMetadata(ctx, &imds.GetMetadataInput{Path: "mac"})
if err != nil {
return "", fmt.Errorf("getting primary MAC address from ec2 metadata: %w", err)
}
defer resp.Content.Close()
mac, err := io.ReadAll(resp.Content)
if err != nil {
return "", fmt.Errorf("reading primary MAC address from ec2 metadata: %w", err)
}

return findPhysicalInterfaceByMAC("/sys/class/net", strings.TrimSpace(string(mac)))
}

// findPhysicalInterfaceByMAC gives the name of the physical network interface that has the
// specified MAC address. The function ignores the virtual interfaces (veths, bridges, VLANs),
// because a virtual interface can have the same MAC address as a physical interface. The
// function gives an error if it does not find exactly one physical interface with this MAC
// address.
func findPhysicalInterfaceByMAC(sysClassNet string, mac string) (string, error) {
entries, err := os.ReadDir(sysClassNet)
if err != nil {
return "", fmt.Errorf("reading %s: %w", sysClassNet, err)
}

var matches []string
for _, entry := range entries {
name := entry.Name()
// The scan uses the "device" symlink in sysfs to know if an interface is physical.
if _, err := os.Stat(filepath.Join(sysClassNet, name, "device")); err != nil {
continue
}
address, err := os.ReadFile(filepath.Join(sysClassNet, name, "address"))
if err != nil {
continue
}
if strings.EqualFold(strings.TrimSpace(string(address)), mac) {
matches = append(matches, name)
}
}

switch len(matches) {
case 1:
return matches[0], nil
case 0:
return "", fmt.Errorf("no physical network interface found with MAC address %q", mac)
default:
return "", fmt.Errorf("multiple physical network interfaces found with MAC address %q: %v", mac, matches)
}
}

// narrowCloudIfupdownHelperRule rewrites Debian 11's
Expand Down
120 changes: 120 additions & 0 deletions nodeup/pkg/model/networking/eni_networking_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
Copyright 2026 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package networking

import (
"os"
"path/filepath"
"testing"
)

func TestFindPhysicalInterfaceByMAC(t *testing.T) {
// If physical is true, the test makes the "device" entry. The scan uses this entry to know
// that the interface is physical, not virtual (veths, bridges, VLANs).
type iface struct {
name string
mac string
physical bool
}

tests := []struct {
name string
interfaces []iface
mac string
expected string
expectError bool
}{
{
name: "primary ens34 on Graviton4",
interfaces: []iface{
{name: "lo", mac: "00:00:00:00:00:00", physical: false},
{name: "ens34", mac: "02:2b:4d:6e:68:dd", physical: true},
{name: "ens35", mac: "02:1c:00:1d:07:d3", physical: true},
},
mac: "02:2b:4d:6e:68:dd",
expected: "ens34",
},
{
name: "case-insensitive match",
interfaces: []iface{
{name: "enp39s0", mac: "02:de:80:e0:00:c9", physical: true},
},
mac: "02:DE:80:E0:00:C9",
expected: "enp39s0",
},
{
name: "virtual interface with cloned MAC is skipped",
interfaces: []iface{
{name: "ens5", mac: "02:a0:ac:d3:05:3b", physical: true},
{name: "vlan5", mac: "02:a0:ac:d3:05:3b", physical: false},
},
mac: "02:a0:ac:d3:05:3b",
expected: "ens5",
},
{
name: "no match",
interfaces: []iface{
{name: "ens5", mac: "02:a0:ac:d3:05:3b", physical: true},
},
mac: "02:ff:ff:ff:ff:ff",
expectError: true,
},
{
name: "duplicate MAC on physical interfaces",
interfaces: []iface{
{name: "ens5", mac: "02:a0:ac:d3:05:3b", physical: true},
{name: "ens6", mac: "02:a0:ac:d3:05:3b", physical: true},
},
mac: "02:a0:ac:d3:05:3b",
expectError: true,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
sysClassNet := t.TempDir()
for _, i := range test.interfaces {
dir := filepath.Join(sysClassNet, i.name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "address"), []byte(i.mac+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if i.physical {
if err := os.Mkdir(filepath.Join(dir, "device"), 0o755); err != nil {
t.Fatal(err)
}
}
}

actual, err := findPhysicalInterfaceByMAC(sysClassNet, test.mac)
if test.expectError {
if err == nil {
t.Fatalf("expected error, got interface %q", actual)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if actual != test.expected {
t.Fatalf("expected interface %q, got %q", test.expected, actual)
}
})
}
}
Loading