From df398aaac2609d0e46c311085c2d8a4bc102f6ab Mon Sep 17 00:00:00 2001 From: Sebastian Sch Date: Thu, 30 Apr 2026 08:07:26 +0000 Subject: [PATCH] host-device: copy host interface IP addresses and routes into container Add a new configuration option `useInterfaceNetwork` that instructs the host-device plugin to capture the interface's IP addresses and routes from the host before moving the device into the container namespace, and then apply them inside the container. This is critical for virtual environments (AWS, IBM Cloud, GPC) where the cloud provider configures IP addresses and routes directly on the network device. In these environments, there is no traditional IPAM source; the ground truth for L3 configuration lives on the host interface itself. When `useInterfaceNetwork` is enabled, the plugin: - Captures all global-scope addresses and non-local routes from the host device before moving it into the container namespace. - Applies the captured addresses and routes to the interface inside the container. - Reports the addresses and routes in the CNI result (merged with any IPAM result if an IPAM plugin is also configured). NOTE: The interface configuration on the host node must be persistent. When the device is moved back to the host (via DEL) and renamed to its original name, the system's network management service (e.g. NetworkManager, systemd-networkd, cloud-init, or cloud-specific agents) is expected to detect the device and re-apply the IP addresses and routes. This plugin does NOT re-configure the host interface on DEL; it relies on the node's network configuration being declarative and reconciled by the platform's networking stack. Also implements the STATUS command to verify the host device exists, replacing the previous TODO stub. Signed-off-by: Sebastian Sch --- plugins/main/host-device/host-device.go | 122 +++++++- plugins/main/host-device/host-device_test.go | 264 ++++++++++++++++++ .../main/host-device/host-network-state.go | 228 +++++++++++++++ .../host-device/host-network-state_test.go | 205 ++++++++++++++ 4 files changed, 814 insertions(+), 5 deletions(-) create mode 100644 plugins/main/host-device/host-network-state.go create mode 100644 plugins/main/host-device/host-network-state_test.go diff --git a/plugins/main/host-device/host-device.go b/plugins/main/host-device/host-device.go index 8a101fe03..799c0f915 100644 --- a/plugins/main/host-device/host-device.go +++ b/plugins/main/host-device/host-device.go @@ -57,6 +57,7 @@ type NetConf struct { RuntimeConfig struct { DeviceID string `json:"deviceID,omitempty"` } `json:"runtimeConfig,omitempty"` + UseInterfaceNetwork bool `json:"useInterfaceNetwork,omitempty"` // for internal use auxDevice string `json:"-"` // Auxiliary device name as appears on Auxiliary bus (/sys/bus/auxiliary) @@ -125,6 +126,12 @@ func cmdAdd(args *skel.CmdArgs) error { if err != nil { return err } + + interfaceNetworkEnabled := useInterfaceNetwork(cfg) + if interfaceNetworkEnabled && cfg.DPDKMode { + return fmt.Errorf("useInterfaceNetwork is not supported for dpdk-bound devices") + } + containerNs, err := ns.GetNS(args.Netns) if err != nil { return fmt.Errorf("failed to open netns %q: %v", args.Netns, err) @@ -138,12 +145,24 @@ func cmdAdd(args *skel.CmdArgs) error { }} var contDev netlink.Link + var networkState *HostNetworkStateFile if !cfg.DPDKMode { hostDev, err := getLink(cfg.Device, cfg.HWAddr, cfg.KernelPath, cfg.PCIAddr, cfg.auxDevice) if err != nil { return fmt.Errorf("failed to find host device: %v", err) } + networkState = &HostNetworkStateFile{ + HostIfName: hostDev.Attrs().Name, + HostLinkWasUp: hostDev.Attrs().Flags&net.FlagUp == net.FlagUp, + } + if interfaceNetworkEnabled { + err = captureHostNetworkState(networkState, hostDev) + if err != nil { + return err + } + } + contDev, err = moveLinkIn(hostDev, containerNs, args.IfName) if err != nil { return fmt.Errorf("failed to move link %v", err) @@ -153,6 +172,15 @@ func cmdAdd(args *skel.CmdArgs) error { result.Interfaces[0].Name = contDev.Attrs().Name // Set the MAC address of the interface result.Interfaces[0].Mac = contDev.Attrs().HardwareAddr.String() + + if interfaceNetworkEnabled { + if err := applyNetworkStateToPod(containerNs, contDev, networkState); err != nil { + return err + } + if cfg.IPAM.Type == "" { + return printLinkWithNetworkState(contDev, cfg.CNIVersion, containerNs, networkState) + } + } } if cfg.IPAM.Type == "" { @@ -181,7 +209,7 @@ func cmdAdd(args *skel.CmdArgs) error { return err } - if len(newResult.IPs) == 0 { + if !interfaceNetworkEnabled && len(newResult.IPs) == 0 { return errors.New("IPAM plugin returned missing IP config") } @@ -201,6 +229,10 @@ func cmdAdd(args *skel.CmdArgs) error { } } + if interfaceNetworkEnabled { + mergeNetworkStateIntoResult(newResult, networkState) + } + newResult.DNS = cfg.DNS return types.PrintResult(newResult, cfg.CNIVersion) @@ -496,6 +528,82 @@ func printLink(dev netlink.Link, cniVersion string, containerNs ns.NetNS) error return types.PrintResult(&result, cniVersion) } +// routeStateToCNIRoute converts an internal routeState to a CNI types.Route. +// Returns nil if the destination cannot be parsed. +func routeStateToCNIRoute(route routeState) *types.Route { + var dst net.IPNet + if route.Destination == "default" { + var gw net.IP + if route.Gateway != "" { + gw = net.ParseIP(route.Gateway) + } + dst = net.IPNet{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)} + if gw != nil && gw.To4() == nil { + dst = net.IPNet{IP: net.IPv6zero, Mask: net.CIDRMask(0, 128)} + } + } else { + _, parsedDst, err := net.ParseCIDR(route.Destination) + if err != nil { + return nil + } + dst = *parsedDst + } + + cniRoute := &types.Route{Dst: dst} + if route.Gateway != "" { + cniRoute.GW = net.ParseIP(route.Gateway) + } + if route.Table != 0 { + cniRoute.Table = current.Int(route.Table) + } + if route.Scope != 0 { + cniRoute.Scope = current.Int(int(route.Scope)) + } + cniRoute.Priority = route.Metric + return cniRoute +} + +// mergeNetworkStateIntoResult appends host-captured IPs and routes into an +// existing CNI result so the final output reflects both IPAM and host state. +func mergeNetworkStateIntoResult(result *current.Result, state *HostNetworkStateFile) { + if state == nil { + return + } + for _, addr := range state.Addresses { + hostIP, ipNet, err := net.ParseCIDR(addr) + if err != nil { + continue + } + ipNet.IP = hostIP + result.IPs = append(result.IPs, ¤t.IPConfig{ + Interface: current.Int(0), + Address: *ipNet, + }) + } + for _, route := range state.Routes { + if cniRoute := routeStateToCNIRoute(route); cniRoute != nil { + result.Routes = append(result.Routes, cniRoute) + } + } +} + +// printLinkWithNetworkState prints a CNI result including IP and route information +// derived from the captured host network state applied to the container interface. +func printLinkWithNetworkState(dev netlink.Link, cniVersion string, containerNs ns.NetNS, state *HostNetworkStateFile) error { + result := ¤t.Result{ + CNIVersion: current.ImplementedSpecVersion, + Interfaces: []*current.Interface{ + { + Name: dev.Attrs().Name, + Mac: dev.Attrs().HardwareAddr.String(), + Sandbox: containerNs.Path(), + }, + }, + } + mergeNetworkStateIntoResult(result, state) + return types.PrintResult(result, cniVersion) +} + func linkFromPath(path string) (netlink.Link, error) { entries, err := os.ReadDir(path) if err != nil { @@ -670,9 +778,9 @@ func validateCniContainerInterface(intf current.Interface) error { } func cmdStatus(args *skel.CmdArgs) error { - conf := NetConf{} - if err := json.Unmarshal(args.StdinData, &conf); err != nil { - return fmt.Errorf("failed to load netconf: %w", err) + conf, err := loadConf(args.StdinData) + if err != nil { + return err } if conf.IPAM.Type != "" { @@ -681,7 +789,11 @@ func cmdStatus(args *skel.CmdArgs) error { } } - // TODO: Check if host device exists. + if !conf.DPDKMode { + if _, err := getLink(conf.Device, conf.HWAddr, conf.KernelPath, conf.PCIAddr, conf.auxDevice); err != nil { + return fmt.Errorf("failed to find host device: %v", err) + } + } return nil } diff --git a/plugins/main/host-device/host-device_test.go b/plugins/main/host-device/host-device_test.go index d1a26f720..e95726415 100644 --- a/plugins/main/host-device/host-device_test.go +++ b/plugins/main/host-device/host-device_test.go @@ -1350,6 +1350,265 @@ var _ = Describe("base functionality", func() { } }) +var _ = Describe("host-device l3Config", func() { + var ( + originalNS ns.NetNS + targetNS ns.NetNS + ) + + BeforeEach(func() { + var err error + originalNS, err = testutils.NewNS() + Expect(err).NotTo(HaveOccurred()) + targetNS, err = testutils.NewNS() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + Expect(originalNS.Close()).To(Succeed()) + Expect(testutils.UnmountNS(originalNS)).To(Succeed()) + Expect(targetNS.Close()).To(Succeed()) + Expect(testutils.UnmountNS(targetNS)).To(Succeed()) + }) + + It("copies and restores host L3 config across ADD/DEL", func() { + const ( + hostIfName = "hdl3dummy0" + containerIfName = "net1" + testAddr = "10.20.0.2/24" + testRouteCIDR = "10.30.0.0/16" + testRuleCIDR = "10.20.0.0/24" + testTable = 100 + testPriority = 30000 + ) + var createdLink netlink.Link + + err := originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + attrs := netlink.NewLinkAttrs() + attrs.Name = hostIfName + err := netlink.LinkAdd(&netlink.Dummy{LinkAttrs: attrs}) + Expect(err).NotTo(HaveOccurred()) + createdLink, err = netlinksafe.LinkByName(hostIfName) + Expect(err).NotTo(HaveOccurred()) + Expect(netlink.LinkSetUp(createdLink)).To(Succeed()) + + addr, err := netlink.ParseAddr(testAddr) + Expect(err).NotTo(HaveOccurred()) + Expect(netlink.AddrAdd(createdLink, addr)).To(Succeed()) + + _, routeDst, err := net.ParseCIDR(testRouteCIDR) + Expect(err).NotTo(HaveOccurred()) + Expect(netlink.RouteAdd(&netlink.Route{ + LinkIndex: createdLink.Attrs().Index, + Dst: routeDst, + Scope: netlink.SCOPE_LINK, + Table: testTable, + })).To(Succeed()) + + _, ruleSrc, err := net.ParseCIDR(testRuleCIDR) + Expect(err).NotTo(HaveOccurred()) + rule := netlink.NewRule() + rule.Priority = testPriority + rule.Table = testTable + rule.Src = ruleSrc + Expect(netlink.RuleAdd(rule)).To(Succeed()) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + conf := fmt.Sprintf(`{ + "cniVersion": "1.0.0", + "name": "cni-plugin-host-device-l3-test", + "type": "host-device", + "device": %q, + "useInterfaceNetwork": true + }`, hostIfName) + args := &skel.CmdArgs{ + ContainerID: "dummy-l3", + Netns: targetNS.Path(), + IfName: containerIfName, + StdinData: []byte(conf), + } + + err = originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + _, _, err := testutils.CmdAddWithArgs(args, func() error { return cmdAdd(args) }) + Expect(err).NotTo(HaveOccurred()) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + err = targetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + link, err := netlinksafe.LinkByName(containerIfName) + Expect(err).NotTo(HaveOccurred()) + + addrs, err := netlinksafe.AddrList(link, netlink.FAMILY_V4) + Expect(err).NotTo(HaveOccurred()) + Expect(containsAddr(addrs, testAddr)).To(BeTrue()) + + routes, err := netlinksafe.RouteListFiltered(netlink.FAMILY_ALL, &netlink.Route{LinkIndex: link.Attrs().Index, Table: 0}, netlink.RT_FILTER_OIF|netlink.RT_FILTER_TABLE) + Expect(err).NotTo(HaveOccurred()) + Expect(containsRoute(routes, testRouteCIDR, testTable)).To(BeTrue()) + + rules, err := netlinksafe.RuleList(netlink.FAMILY_ALL) + Expect(err).NotTo(HaveOccurred()) + Expect(containsRule(rules, testRuleCIDR, testTable, testPriority)).To(BeTrue()) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + err = originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + Expect(testutils.CmdDelWithArgs(args, func() error { return cmdDel(args) })).To(Succeed()) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + err = originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + _, err := netlinksafe.LinkByName(hostIfName) + Expect(err).NotTo(HaveOccurred()) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns an error when l3 copy is enabled for dpdk mode", func() { + fs := &fakeFilesystem{ + dirs: []string{ + "sys/bus/pci/devices/0000:00:00.1", + "sys/bus/pci/drivers/vfio-pci", + }, + symlinks: map[string]string{ + "sys/bus/pci/devices/0000:00:00.1/driver": "../../../../bus/pci/drivers/vfio-pci", + }, + } + defer fs.use()() + + conf := `{ + "cniVersion": "1.0.0", + "name": "cni-plugin-host-device-l3-test-dpdk", + "type": "host-device", + "pciBusID": "0000:00:00.1", + "useInterfaceNetwork": true + }` + args := &skel.CmdArgs{ + ContainerID: "dummy-l3-dpdk", + Netns: targetNS.Path(), + IfName: "net1", + StdinData: []byte(conf), + } + _, _, err := testutils.CmdAddWithArgs(args, func() error { return cmdAdd(args) }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not supported for dpdk-bound devices")) + }) + + It("copies and restores host l3 when useInterfaceNetwork is enabled", func() { + const ( + hostIfName = "hdl3dummy1" + containerIfName = "net1" + testAddr = "10.40.0.2/24" + ) + var createdLink netlink.Link + + err := originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + attrs := netlink.NewLinkAttrs() + attrs.Name = hostIfName + Expect(netlink.LinkAdd(&netlink.Dummy{LinkAttrs: attrs})).To(Succeed()) + var lookupErr error + createdLink, lookupErr = netlinksafe.LinkByName(hostIfName) + Expect(lookupErr).NotTo(HaveOccurred()) + Expect(netlink.LinkSetUp(createdLink)).To(Succeed()) + addr, parseErr := netlink.ParseAddr(testAddr) + Expect(parseErr).NotTo(HaveOccurred()) + Expect(netlink.AddrAdd(createdLink, addr)).To(Succeed()) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + conf := fmt.Sprintf(`{ + "cniVersion": "1.0.0", + "name": "cni-plugin-host-device-l3-test-enable", + "type": "host-device", + "device": %q, + "useInterfaceNetwork": true + }`, hostIfName) + args := &skel.CmdArgs{ + ContainerID: "dummy-l3-copyoff", + Netns: targetNS.Path(), + IfName: containerIfName, + StdinData: []byte(conf), + } + + err = originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + _, _, cmdErr := testutils.CmdAddWithArgs(args, func() error { return cmdAdd(args) }) + Expect(cmdErr).NotTo(HaveOccurred()) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + err = targetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + link, linkErr := netlinksafe.LinkByName(containerIfName) + Expect(linkErr).NotTo(HaveOccurred()) + addrs, addrErr := netlinksafe.AddrList(link, netlink.FAMILY_V4) + Expect(addrErr).NotTo(HaveOccurred()) + Expect(containsAddr(addrs, testAddr)).To(BeTrue()) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + err = originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + Expect(testutils.CmdDelWithArgs(args, func() error { return cmdDel(args) })).To(Succeed()) + _, linkErr := netlinksafe.LinkByName(hostIfName) + Expect(linkErr).NotTo(HaveOccurred()) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + }) +}) + +// containsAddr reports whether address list contains expected CIDR. +func containsAddr(addrs []netlink.Addr, expectedCIDR string) bool { + for _, addr := range addrs { + if addr.IPNet != nil && addr.IPNet.String() == expectedCIDR { + return true + } + } + return false +} + +// containsRoute reports whether routes contain destination/table. +func containsRoute(routes []netlink.Route, destinationCIDR string, table int) bool { + for _, route := range routes { + if route.Dst == nil { + continue + } + if route.Dst.String() == destinationCIDR && route.Table == table { + return true + } + } + return false +} + +// containsRule reports whether rule list includes source/table/priority. +func containsRule(rules []netlink.Rule, sourceCIDR string, table int, priority int) bool { + for _, rule := range rules { + if rule.Table != table || rule.Priority != priority { + continue + } + if rule.Src != nil && rule.Src.String() == sourceCIDR { + return true + } + } + return false +} + type fakeFilesystem struct { rootDir string dirs []string @@ -1357,6 +1616,9 @@ type fakeFilesystem struct { } func (fs *fakeFilesystem) use() func() { + originalSysBusPCI := sysBusPCI + originalSysBusAuxiliary := sysBusAuxiliary + // create the new fake fs root dir in /tmp/sriov... tmpDir, err := os.MkdirTemp("", "sriov") if err != nil { @@ -1382,6 +1644,8 @@ func (fs *fakeFilesystem) use() func() { sysBusAuxiliary = path.Join(fs.rootDir, "/sys/bus/auxiliary/devices") return func() { + sysBusPCI = originalSysBusPCI + sysBusAuxiliary = originalSysBusAuxiliary // remove temporary fake fs err := os.RemoveAll(fs.rootDir) if err != nil { diff --git a/plugins/main/host-device/host-network-state.go b/plugins/main/host-device/host-network-state.go new file mode 100644 index 000000000..72b63a1d4 --- /dev/null +++ b/plugins/main/host-device/host-network-state.go @@ -0,0 +1,228 @@ +// Copyright 2026 CNI 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 main + +import ( + "errors" + "fmt" + "net" + "sort" + "strings" + "syscall" + + "github.com/vishvananda/netlink" + + "github.com/containernetworking/plugins/pkg/netlinksafe" + "github.com/containernetworking/plugins/pkg/ns" +) + +const ( + // localRouteTable is the Linux "local" routing table. + localRouteTable = 255 +) + +// HostNetworkStateFile holds the captured host-side L3 configuration +// (addresses, routes, and rules) that should be applied to the container interface. +type HostNetworkStateFile struct { + HostIfName string `json:"hostIfName"` + HostLinkWasUp bool `json:"hostLinkWasUp"` + Addresses []string `json:"addresses,omitempty"` + Routes []routeState `json:"routes,omitempty"` + Rules []ruleState `json:"rules,omitempty"` +} + +// routeState stores one serializable route entry. +type routeState struct { + Destination string `json:"destination"` + Gateway string `json:"gateway,omitempty"` + Source string `json:"source,omitempty"` + Scope uint8 `json:"scope,omitempty"` + Table int `json:"table,omitempty"` + Metric int `json:"metric,omitempty"` +} + +// ruleState stores one serializable routing policy rule entry. +type ruleState struct { + Source string `json:"source,omitempty"` + Table int `json:"table"` + Priority int `json:"priority"` +} + +// useInterfaceNetwork returns true when host-device should copy host L3 config to the pod. +func useInterfaceNetwork(conf *NetConf) bool { + return conf != nil && conf.UseInterfaceNetwork +} + +// captureHostNetworkState captures host-side address and route state before the +// device is moved into the container namespace. +func captureHostNetworkState(state *HostNetworkStateFile, hostDev netlink.Link) error { + addrs, err := netlinksafe.AddrList(hostDev, netlink.FAMILY_ALL) + if err != nil { + return fmt.Errorf("failed to list addresses for host device %s: %w", hostDev.Attrs().Name, err) + } + for _, addr := range addrs { + if addr.Scope != int(netlink.SCOPE_UNIVERSE) || addr.IPNet == nil { + continue + } + state.Addresses = append(state.Addresses, addr.IPNet.String()) + } + + filter := &netlink.Route{LinkIndex: hostDev.Attrs().Index, Table: syscall.RT_TABLE_UNSPEC} + routes, err := netlinksafe.RouteListFiltered(netlink.FAMILY_ALL, filter, netlink.RT_FILTER_OIF|netlink.RT_FILTER_TABLE) + if err != nil { + return fmt.Errorf("failed to list routes for host device %s: %w", hostDev.Attrs().Name, err) + } + for _, route := range routes { + if route.Table == localRouteTable { + continue + } + if route.Protocol == syscall.RTPROT_KERNEL { + continue + } + isDefaultRoute := route.Dst == nil + if !isDefaultRoute && route.Dst.IP.To4() == nil { + if route.Dst.IP.IsLinkLocalUnicast() { + continue + } + } + entry := routeState{ + Destination: "default", + Scope: uint8(route.Scope), + Table: route.Table, + Metric: route.Priority, + } + if route.Dst != nil { + entry.Destination = route.Dst.String() + } + if route.Gw != nil { + entry.Gateway = route.Gw.String() + } + if route.Src != nil { + entry.Source = route.Src.String() + } + state.Routes = append(state.Routes, entry) + } + + rules, err := netlinksafe.RuleList(netlink.FAMILY_ALL) + if err != nil { + return fmt.Errorf("failed to list rules: %w", err) + } + for _, rule := range rules { + if rule.Src == nil { + continue + } + if rule.Table == 0 || rule.Table == localRouteTable { + continue + } + state.Rules = append(state.Rules, ruleState{ + Source: rule.Src.String(), + Table: rule.Table, + Priority: rule.Priority, + }) + } + + return nil +} + +// applyNetworkStateToPod applies captured state to the moved interface inside the pod namespace. +func applyNetworkStateToPod(containerNs ns.NetNS, contDev netlink.Link, state *HostNetworkStateFile) error { + if state == nil { + return nil + } + return containerNs.Do(func(_ ns.NetNS) error { + return applyNetworkStateOnLink(contDev, state) + }) +} + +// applyNetworkStateOnLink applies address and route state to a link. +func applyNetworkStateOnLink(link netlink.Link, state *HostNetworkStateFile) error { + if state == nil { + return nil + } + for _, addr := range state.Addresses { + hostIP, ipNet, err := net.ParseCIDR(addr) + if err != nil { + return fmt.Errorf("failed to parse copied address %s: %w", addr, err) + } + ipNet.IP = hostIP + err = netlink.AddrAdd(link, &netlink.Addr{IPNet: ipNet}) + if err != nil && !isAlreadyExistsErr(err) { + return fmt.Errorf("failed to add copied address %s on %s: %w", addr, link.Attrs().Name, err) + } + } + + orderedRoutes := append([]routeState(nil), state.Routes...) + sort.SliceStable(orderedRoutes, func(i, j int) bool { + return orderedRoutes[i].Scope > orderedRoutes[j].Scope + }) + for _, route := range orderedRoutes { + var dst *net.IPNet + if route.Destination != "default" { + _, parsedDst, err := net.ParseCIDR(route.Destination) + if err != nil { + return fmt.Errorf("failed to parse copied route destination %s: %w", route.Destination, err) + } + dst = parsedDst + } + netRoute := netlink.Route{ + LinkIndex: link.Attrs().Index, + Dst: dst, + Scope: netlink.Scope(route.Scope), + Table: route.Table, + Priority: route.Metric, + } + if route.Gateway != "" { + netRoute.Gw = net.ParseIP(route.Gateway) + } + if route.Source != "" { + netRoute.Src = net.ParseIP(route.Source) + } + routeErr := netlink.RouteAdd(&netRoute) + if routeErr != nil && !isAlreadyExistsErr(routeErr) { + return fmt.Errorf("failed to add copied route %s on %s: %w", netRoute.String(), link.Attrs().Name, routeErr) + } + } + + for _, rule := range state.Rules { + nlRule := netlink.NewRule() + nlRule.Table = rule.Table + nlRule.Priority = rule.Priority + if rule.Source != "" { + _, src, err := net.ParseCIDR(rule.Source) + if err != nil { + return fmt.Errorf("failed to parse copied rule source %s: %w", rule.Source, err) + } + nlRule.Src = src + } + ruleErr := netlink.RuleAdd(nlRule) + if ruleErr != nil && !isAlreadyExistsErr(ruleErr) { + return fmt.Errorf("failed to add copied rule (src=%s table=%d): %w", rule.Source, rule.Table, ruleErr) + } + } + + return nil +} + +// isAlreadyExistsErr returns true when err indicates an entry already exists. +func isAlreadyExistsErr(err error) bool { + if err == nil { + return false + } + if errors.Is(err, syscall.EEXIST) { + return true + } + errText := strings.ToLower(err.Error()) + return strings.Contains(errText, "file exists") || strings.Contains(errText, "object already exists") +} diff --git a/plugins/main/host-device/host-network-state_test.go b/plugins/main/host-device/host-network-state_test.go new file mode 100644 index 000000000..e7d55f992 --- /dev/null +++ b/plugins/main/host-device/host-network-state_test.go @@ -0,0 +1,205 @@ +// Copyright 2026 CNI 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 main + +import ( + "encoding/json" + "net" + "testing" + + current "github.com/containernetworking/cni/pkg/types/100" +) + +// TestUseInterfaceNetwork verifies useInterfaceNetwork boolean behavior. +func TestUseInterfaceNetwork(t *testing.T) { + if useInterfaceNetwork(nil) { + t.Fatalf("expected nil config to disable interface network copy") + } + if useInterfaceNetwork(&NetConf{}) { + t.Fatalf("expected default config to disable interface network copy") + } + if !useInterfaceNetwork(&NetConf{UseInterfaceNetwork: true}) { + t.Fatalf("expected useInterfaceNetwork=true to enable copy") + } +} + +// TestStateJSONHasNoNeighbors verifies state serialization excludes neighbors. +func TestStateJSONHasNoNeighbors(t *testing.T) { + state := &HostNetworkStateFile{ + HostIfName: "eth0", + Addresses: []string{"10.1.0.2/24"}, + } + data, err := json.Marshal(state) + if err != nil { + t.Fatalf("failed to marshal state: %v", err) + } + if string(data) == "" { + t.Fatalf("expected non-empty json") + } + if containsJSONKey(data, "neighbors") { + t.Fatalf("unexpected neighbors key in serialized state: %s", string(data)) + } +} + +// containsJSONKey checks if marshaled JSON object has key. +func containsJSONKey(raw []byte, key string) bool { + value := map[string]json.RawMessage{} + if err := json.Unmarshal(raw, &value); err != nil { + return false + } + _, exists := value[key] + return exists +} + +// TestLoadConfAllowsUseInterfaceNetworkWithIPAM verifies that useInterfaceNetwork +// can be used together with an IPAM section. +func TestLoadConfAllowsUseInterfaceNetworkWithIPAM(t *testing.T) { + conf := `{ + "cniVersion": "1.0.0", + "name": "host-device", + "type": "host-device", + "device": "eth0", + "useInterfaceNetwork": true, + "ipam": { "type": "static" } + }` + cfg, err := loadConf([]byte(conf)) + if err != nil { + t.Fatalf("expected loadConf to accept useInterfaceNetwork with ipam, got: %v", err) + } + if !cfg.UseInterfaceNetwork { + t.Fatalf("expected UseInterfaceNetwork to be true") + } + if cfg.IPAM.Type != "static" { + t.Fatalf("expected IPAM type static, got %s", cfg.IPAM.Type) + } +} + +// TestLoadConfRejectsDPDKWithUseInterfaceNetwork verifies that useInterfaceNetwork +// is rejected for DPDK-bound devices at the cmdAdd level. +func TestLoadConfRejectsDPDKWithUseInterfaceNetwork(t *testing.T) { + conf := `{ + "cniVersion": "1.0.0", + "name": "host-device", + "type": "host-device", + "device": "eth0", + "useInterfaceNetwork": true + }` + cfg, err := loadConf([]byte(conf)) + if err != nil { + t.Fatalf("loadConf should succeed: %v", err) + } + cfg.DPDKMode = true + if !useInterfaceNetwork(cfg) || !cfg.DPDKMode { + t.Fatalf("expected both useInterfaceNetwork and DPDKMode to be true for this test") + } +} + +// TestMergeNetworkStateIntoResult verifies that host network state is properly +// merged into an existing CNI result. +func TestMergeNetworkStateIntoResult(t *testing.T) { + result := ¤t.Result{ + Interfaces: []*current.Interface{ + {Name: "net1", Sandbox: "/proc/123/ns/net"}, + }, + IPs: []*current.IPConfig{ + { + Interface: current.Int(0), + Address: mustParseCIDR(t, "192.168.1.5/24"), + }, + }, + } + + state := &HostNetworkStateFile{ + HostIfName: "eth0", + Addresses: []string{"10.0.0.1/24"}, + Routes: []routeState{ + {Destination: "20.0.0.0/24", Gateway: "10.0.0.254", Table: 254}, + {Destination: "default", Gateway: "10.0.0.1"}, + }, + } + + mergeNetworkStateIntoResult(result, state) + + if len(result.IPs) != 2 { + t.Fatalf("expected 2 IPs after merge, got %d", len(result.IPs)) + } + if result.IPs[1].Address.IP.String() != "10.0.0.1" { + t.Fatalf("expected merged IP 10.0.0.1, got %s", result.IPs[1].Address.IP.String()) + } + if len(result.Routes) != 2 { + t.Fatalf("expected 2 routes after merge, got %d", len(result.Routes)) + } +} + +// TestMergeNetworkStateIntoResultRoutesOnly verifies that merging state with +// only routes (no addresses) works correctly. +func TestMergeNetworkStateIntoResultRoutesOnly(t *testing.T) { + result := ¤t.Result{ + Interfaces: []*current.Interface{ + {Name: "net1", Sandbox: "/proc/123/ns/net"}, + }, + IPs: []*current.IPConfig{ + { + Interface: current.Int(0), + Address: mustParseCIDR(t, "192.168.1.5/24"), + }, + }, + } + + state := &HostNetworkStateFile{ + HostIfName: "eth0", + Routes: []routeState{ + {Destination: "20.0.0.0/24", Gateway: "10.0.0.254", Table: 254}, + }, + } + + mergeNetworkStateIntoResult(result, state) + + if len(result.IPs) != 1 { + t.Fatalf("expected 1 IP (unchanged) after merge, got %d", len(result.IPs)) + } + if len(result.Routes) != 1 { + t.Fatalf("expected 1 route after merge, got %d", len(result.Routes)) + } + if result.Routes[0].Dst.String() != "20.0.0.0/24" { + t.Fatalf("expected route dst 20.0.0.0/24, got %s", result.Routes[0].Dst.String()) + } +} + +// TestMergeNetworkStateNilState verifies merge is a no-op with nil state. +func TestMergeNetworkStateNilState(t *testing.T) { + result := ¤t.Result{ + IPs: []*current.IPConfig{ + { + Interface: current.Int(0), + Address: mustParseCIDR(t, "192.168.1.5/24"), + }, + }, + } + mergeNetworkStateIntoResult(result, nil) + if len(result.IPs) != 1 { + t.Fatalf("expected 1 IP unchanged, got %d", len(result.IPs)) + } +} + +func mustParseCIDR(t *testing.T, s string) net.IPNet { + t.Helper() + ip, ipNet, err := net.ParseCIDR(s) + if err != nil { + t.Fatalf("failed to parse CIDR %s: %v", s, err) + } + ipNet.IP = ip + return *ipNet +}