From 10936b3c92a323dfb4349cde0230c4d260e84a1b Mon Sep 17 00:00:00 2001 From: Sebastian Sch Date: Mon, 11 May 2026 13:04:09 +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 --- .gitignore | 1 + plugins/main/host-device/host-device.go | 120 +++++++- plugins/main/host-device/host-device_test.go | 264 ++++++++++++++++++ .../main/host-device/host-network-state.go | 219 +++++++++++++++ .../host-device/host-network-state_test.go | 154 ++++++++++ 5 files changed, 753 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/.gitignore b/.gitignore index 6e7ccd20b..092330967 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ gopath/ .vagrant .idea /release-* +host-device diff --git a/plugins/main/host-device/host-device.go b/plugins/main/host-device/host-device.go index 8a101fe03..9b0e07b00 100644 --- a/plugins/main/host-device/host-device.go +++ b/plugins/main/host-device/host-device.go @@ -57,6 +57,11 @@ type NetConf struct { RuntimeConfig struct { DeviceID string `json:"deviceID,omitempty"` } `json:"runtimeConfig,omitempty"` + // When true, capture the host interface's IP addresses and routes and apply + // them inside the container. Useful in cloud/virtual environments where L3 + // config is provisioned directly on the host device. Can be combined with + // IPAM to add extra addresses or routes on top of the host-provided ones. + UseInterfaceNetwork bool `json:"useInterfaceNetwork,omitempty"` // for internal use auxDevice string `json:"-"` // Auxiliary device name as appears on Auxiliary bus (/sys/bus/auxiliary) @@ -125,6 +130,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 +149,24 @@ func cmdAdd(args *skel.CmdArgs) error { }} var contDev netlink.Link + var networkState *HostNetworkState 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 = &HostNetworkState{ + 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 +176,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 := networkState.applyToPod(containerNs, contDev); err != nil { + return err + } + if cfg.IPAM.Type == "" { + return printLinkWithNetworkState(contDev, cfg.CNIVersion, containerNs, networkState) + } + } } if cfg.IPAM.Type == "" { @@ -181,7 +213,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 +233,10 @@ func cmdAdd(args *skel.CmdArgs) error { } } + if interfaceNetworkEnabled { + mergeNetworkStateIntoResult(newResult, networkState) + } + newResult.DNS = cfg.DNS return types.PrintResult(newResult, cfg.CNIVersion) @@ -496,6 +532,76 @@ func printLink(dev netlink.Link, cniVersion string, containerNs ns.NetNS) error return types.PrintResult(&result, cniVersion) } +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 +} + +func mergeNetworkStateIntoResult(result *current.Result, state *HostNetworkState) { + 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) + } + } +} + +func printLinkWithNetworkState(dev netlink.Link, cniVersion string, containerNs ns.NetNS, state *HostNetworkState) 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 +776,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 +787,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..eee5d350c --- /dev/null +++ b/plugins/main/host-device/host-network-state.go @@ -0,0 +1,219 @@ +// 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 = 255 +) + +// HostNetworkState holds the captured host-side L3 configuration +// (addresses, routes, and rules) that should be applied to the container interface. +type HostNetworkState struct { + HostIfName string `json:"hostIfName"` + HostLinkWasUp bool `json:"hostLinkWasUp"` + Addresses []string `json:"addresses,omitempty"` + Routes []routeState `json:"routes,omitempty"` + Rules []ruleState `json:"rules,omitempty"` +} + +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"` +} + +type ruleState struct { + Source string `json:"source,omitempty"` + Table int `json:"table"` + Priority int `json:"priority"` +} + +func useInterfaceNetwork(conf *NetConf) bool { + return conf != nil && conf.UseInterfaceNetwork +} + +func captureHostNetworkState(state *HostNetworkState, 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 +} + +func (state *HostNetworkState) applyToPod(containerNs ns.NetNS, contDev netlink.Link) error { + if state == nil { + return nil + } + return containerNs.Do(func(_ ns.NetNS) error { + return state.applyOnLink(contDev) + }) +} + +func (state *HostNetworkState) applyOnLink(link netlink.Link) 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 +} + +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..aa8b5d092 --- /dev/null +++ b/plugins/main/host-device/host-network-state_test.go @@ -0,0 +1,154 @@ +// 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 ( + "net" + "testing" + + current "github.com/containernetworking/cni/pkg/types/100" +) + +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) + } +} + +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") + } +} + +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 := &HostNetworkState{ + 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)) + } +} + +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 := &HostNetworkState{ + 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()) + } +} + +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 +}