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 +}