diff --git a/cmd/nerdctl/network/network_inspect_test.go b/cmd/nerdctl/network/network_inspect_test.go index 3b7e5276420..980ade0ad57 100644 --- a/cmd/nerdctl/network/network_inspect_test.go +++ b/cmd/nerdctl/network/network_inspect_test.go @@ -19,6 +19,7 @@ package network import ( "encoding/json" "errors" + "net" "os/exec" "runtime" "strings" @@ -37,7 +38,7 @@ import ( "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) -func TestNetworkInspect(t *testing.T) { +func TestNetworkInspectBasic(t *testing.T) { testCase := nerdtest.Setup() const ( @@ -46,15 +47,6 @@ func TestNetworkInspect(t *testing.T) { testIPRange = "10.24.24.0/25" ) - testCase.Setup = func(data test.Data, helpers test.Helpers) { - helpers.Ensure("network", "create", data.Identifier("basenet")) - data.Labels().Set("basenet", data.Identifier("basenet")) - } - - testCase.Cleanup = func(data test.Data, helpers test.Helpers) { - helpers.Anyhow("network", "rm", data.Identifier("basenet")) - } - testCase.SubTests = []*test.Case{ { Description: "non existent network", @@ -133,6 +125,59 @@ func TestNetworkInspect(t *testing.T) { assert.Equal(t, dc[0].Name, "custom") }), }, + { + Description: "basic", + // FIXME: IPAMConfig is not implemented on Windows yet + Require: require.Not(require.Windows), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", "--label", "tag=testNetwork", "--subnet", testSubnet, + "--gateway", testGateway, "--ip-range", testIPRange, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("network", "inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + var dc []dockercompat.Network + + err := json.Unmarshal([]byte(stdout), &dc) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") + got := dc[0] + + assert.Equal(t, got.Name, data.Identifier()) + assert.Equal(t, got.Labels["tag"], "testNetwork") + assert.Equal(t, len(got.IPAM.Config), 1) + assert.Equal(t, got.IPAM.Config[0].Subnet, testSubnet) + assert.Equal(t, got.IPAM.Config[0].Gateway, testGateway) + assert.Equal(t, got.IPAM.Config[0].IPRange, testIPRange) + }, + } + }, + }, + } + + testCase.Run(t) +} + +func TestNetworkInspectByID(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", data.Identifier("basenet")) + data.Labels().Set("basenet", data.Identifier("basenet")) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Identifier("basenet")) + } + + testCase.SubTests = []*test.Case{ { Description: "match exact id", // See notes below @@ -201,41 +246,6 @@ func TestNetworkInspect(t *testing.T) { } }, }, - { - Description: "basic", - // FIXME: IPAMConfig is not implemented on Windows yet - Require: require.Not(require.Windows), - Setup: func(data test.Data, helpers test.Helpers) { - helpers.Ensure("network", "create", "--label", "tag=testNetwork", "--subnet", testSubnet, - "--gateway", testGateway, "--ip-range", testIPRange, data.Identifier()) - }, - Cleanup: func(data test.Data, helpers test.Helpers) { - helpers.Anyhow("network", "rm", data.Identifier()) - }, - Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { - return helpers.Command("network", "inspect", data.Identifier()) - }, - Expected: func(data test.Data, helpers test.Helpers) *test.Expected { - return &test.Expected{ - ExitCode: 0, - Output: func(stdout string, t tig.T) { - var dc []dockercompat.Network - - err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n") - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") - got := dc[0] - - assert.Equal(t, got.Name, data.Identifier()) - assert.Equal(t, got.Labels["tag"], "testNetwork") - assert.Equal(t, len(got.IPAM.Config), 1) - assert.Equal(t, got.IPAM.Config[0].Subnet, testSubnet) - assert.Equal(t, got.IPAM.Config[0].Gateway, testGateway) - assert.Equal(t, got.IPAM.Config[0].IPRange, testIPRange) - }, - } - }, - }, { Description: "with namespace", Require: require.Not(nerdtest.Docker), @@ -287,6 +297,15 @@ func TestNetworkInspect(t *testing.T) { } }, }, + } + + testCase.Run(t) +} + +func TestNetworkInspectWithContainers(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ { Description: "Verify that only active containers appear in the network inspect output", Setup: func(data test.Data, helpers test.Helpers) { @@ -397,6 +416,173 @@ func TestNetworkInspect(t *testing.T) { } }, }, + { + Description: "Test container network details", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", data.Identifier("test-network")) + + // See https://github.com/containerd/nerdctl/issues/4322 + if runtime.GOOS == "windows" { + time.Sleep(time.Second) + } + + // Create and start a container on this network + helpers.Ensure("run", "-d", "--name", data.Identifier("test-container"), + "--network", data.Identifier("test-network"), + testutil.CommonImage, "sleep", nerdtest.Infinity) + + // Get container ID for later use + containerID := strings.Trim(helpers.Capture("inspect", data.Identifier("test-container"), "--format", "{{.Id}}"), "\n") + data.Labels().Set("containerID", containerID) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("test-container")) + helpers.Anyhow("network", "remove", data.Identifier("test-network")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("network", "inspect", data.Identifier("test-network")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, t tig.T) { + var dc []dockercompat.Network + err := json.Unmarshal([]byte(stdout), &dc) + assert.NilError(t, err, "Unable to unmarshal output") + assert.Equal(t, 1, len(dc), "Expected exactly one network") + + network := dc[0] + assert.Equal(t, network.Name, data.Identifier("test-network")) + assert.Equal(t, 1, len(network.Containers), "Expected exactly one container") + + // Get the container details + containerID := data.Labels().Get("containerID") + container := network.Containers[containerID] + + // Test container name + assert.Equal(t, container.Name, data.Identifier("test-container")) + + // Windows InspectNetNS is not implemented + if runtime.GOOS != "windows" { + // Verify IPv4Address is not empty and has CIDR notation + assert.Assert(t, container.IPv4Address != "", "IPv4Address should not be empty") + assert.Assert(t, strings.Contains(container.IPv4Address, "/"), "IPv4Address should contain CIDR notation with /") + + // Verify IPv4Address is within the network's subnet + if len(network.IPAM.Config) > 0 && network.IPAM.Config[0].Subnet != "" { + _, subnet, err := net.ParseCIDR(network.IPAM.Config[0].Subnet) + assert.NilError(t, err, "Failed to parse network subnet") + + containerIP, _, err := net.ParseCIDR(container.IPv4Address) + assert.NilError(t, err, "Failed to parse container IPv4Address") + assert.Assert(t, subnet.Contains(containerIP), "IPv4Address should be within the network's subnet") + } + + // Test MacAddress is present and has valid format + assert.Assert(t, container.MacAddress != "", "MacAddress should not be empty") + + // Test IPv6Address is empty for IPv4-only network + assert.Equal(t, "", container.IPv6Address, "IPv6Address should be empty for IPv4-only network") + } + }, + } + }, + }, + } + + testCase.Run(t) +} + +func TestNetworkInspectDualStack(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "Test dual-stack network with both IPv4 and IPv6", + Require: require.Not(require.Windows), // NetNS not implemented on Windows + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", + "--ipv6", + "--subnet", "10.1.0.0/24", + "--subnet", "fd00::/64", + data.Identifier("test-dual-stack")) + + // See https://github.com/containerd/nerdctl/issues/4322 + if runtime.GOOS == "windows" { + time.Sleep(time.Second) + } + + // Create and start a container on this dual-stack network + helpers.Ensure("run", "-d", + "--name", data.Identifier("test-container"), + "--network", data.Identifier("test-dual-stack"), + testutil.CommonImage, "sleep", nerdtest.Infinity) + + // Get container ID for later use + containerID := strings.Trim(helpers.Capture("inspect", data.Identifier("test-container"), "--format", "{{.Id}}"), "\n") + data.Labels().Set("containerID", containerID) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("test-container")) + helpers.Anyhow("network", "remove", data.Identifier("test-dual-stack")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("network", "inspect", data.Identifier("test-dual-stack")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, t tig.T) { + var dc []dockercompat.Network + err := json.Unmarshal([]byte(stdout), &dc) + assert.NilError(t, err, "Unable to unmarshal output") + assert.Equal(t, 1, len(dc), "Expected exactly one network") + + network := dc[0] + assert.Equal(t, network.Name, data.Identifier("test-dual-stack")) + assert.Equal(t, 2, len(network.IPAM.Config), "Expected two subnets (IPv4 and IPv6)") + + // Get the container details + containerID := data.Labels().Get("containerID") + container := network.Containers[containerID] + + // Test container name + assert.Equal(t, container.Name, data.Identifier("test-container")) + + // Parse both subnets + var ipv4Subnet, ipv6Subnet *net.IPNet + for _, config := range network.IPAM.Config { + if config.Subnet != "" { + _, subnet, err := net.ParseCIDR(config.Subnet) + assert.NilError(t, err, "Failed to parse subnet") + if subnet.IP.To4() != nil { + ipv4Subnet = subnet + } else { + ipv6Subnet = subnet + } + } + } + + // Verify IPv4 address is present and within subnet + assert.Assert(t, container.IPv4Address != "", "IPv4Address should not be empty in dual-stack network") + ipv4, _, err := net.ParseCIDR(container.IPv4Address) + assert.NilError(t, err, "Failed to parse IPv4Address") + if ipv4Subnet != nil { + assert.Assert(t, ipv4Subnet.Contains(ipv4), "IPv4 address should be within the IPv4 subnet") + } + + // Verify IPv6 address is present and within subnet + assert.Assert(t, container.IPv6Address != "", "IPv6Address should not be empty in dual-stack network") + ipv6, _, err := net.ParseCIDR(container.IPv6Address) + assert.NilError(t, err, "Failed to parse IPv6Address") + if ipv6Subnet != nil { + assert.Assert(t, ipv6Subnet.Contains(ipv6), "IPv6 address should be within the IPv6 subnet") + } + + // Verify MAC address is present + assert.Assert(t, container.MacAddress != "", "MacAddress should not be empty") + }, + } + }, + }, } testCase.Run(t) diff --git a/pkg/inspecttypes/dockercompat/dockercompat.go b/pkg/inspecttypes/dockercompat/dockercompat.go index 5ebfb0c2980..d684c1feced 100644 --- a/pkg/inspecttypes/dockercompat/dockercompat.go +++ b/pkg/inspecttypes/dockercompat/dockercompat.go @@ -929,9 +929,9 @@ type Network struct { type EndpointResource struct { Name string `json:"Name"` // EndpointID string `json:"EndpointID"` - // MacAddress string `json:"MacAddress"` - // IPv4Address string `json:"IPv4Address"` - // IPv6Address string `json:"IPv6Address"` + MacAddress string `json:"MacAddress"` + IPv4Address string `json:"IPv4Address"` + IPv6Address string `json:"IPv6Address"` } type structuredCNI struct { @@ -949,6 +949,92 @@ type MemorySetting struct { DisableOOMKiller bool `json:"disableOOMKiller"` } +// parseNetworkSubnets extracts and parses subnet configurations from IPAM config +func parseNetworkSubnets(ipamConfigs []IPAMConfig) []*net.IPNet { + var subnets []*net.IPNet + for _, config := range ipamConfigs { + if config.Subnet != "" { + _, subnet, err := net.ParseCIDR(config.Subnet) + if err != nil { + log.L.WithError(err).Warnf("failed to parse subnet %q", config.Subnet) + continue + } + subnets = append(subnets, subnet) + } + } + return subnets +} + +// isUsableInterface checks if a network interface is usable (not loopback and interface is up) +func isUsableInterface(iface *native.NetInterface) bool { + return iface.Interface.Flags&net.FlagLoopback == 0 && + iface.Interface.Flags&net.FlagUp != 0 +} + +// setIPAddresses assigns IPv4 or IPv6 addresses from CIDR notation to the endpoint +func setIPAddresses(endpoint *EndpointResource, cidr string) { + ip, _, err := net.ParseCIDR(cidr) + if err != nil { + return + } + if ip.IsLoopback() || ip.IsLinkLocalUnicast() { + return + } + + if ip.To4() != nil { + endpoint.IPv4Address = cidr + } else if ip.To16() != nil { + endpoint.IPv6Address = cidr + } +} + +// matchInterfaceToSubnets tries to match an interface to network subnets +func matchInterfaceToSubnets(endpoint *EndpointResource, iface *native.NetInterface, subnets []*net.IPNet) bool { + matched := false + for _, addr := range iface.Addrs { + ip, _, err := net.ParseCIDR(addr) + if err != nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + continue + } + + for _, subnet := range subnets { + if subnet.Contains(ip) { + if !matched { + endpoint.MacAddress = iface.HardwareAddr + matched = true + } + setIPAddresses(endpoint, addr) + break // Break inner loop, continue checking other addresses + } + } + } + return matched +} + +// populateEndpointFromNetNS finds and populates endpoint info from network namespace interfaces +func populateEndpointFromNetNS(endpoint *EndpointResource, interfaces []native.NetInterface, subnets []*net.IPNet) { + for _, iface := range interfaces { + if !isUsableInterface(&iface) { + continue + } + + if len(subnets) > 0 { + if matchInterfaceToSubnets(endpoint, &iface, subnets) { + return // Found matching interface + } + // Continue to next interface if this one doesn't match any subnets + continue + } + + // Fallback: use first usable interface (for networks without explicit subnets) + endpoint.MacAddress = iface.HardwareAddr + for _, addr := range iface.Addrs { + setIPAddresses(endpoint, addr) + } + return + } +} + func NetworkFromNative(n *native.Network) (*Network, error) { var res Network @@ -973,15 +1059,20 @@ func NetworkFromNative(n *native.Network) (*Network, error) { res.Labels = *n.NerdctlLabels } + // Parse network subnets for interface matching + networkSubnets := parseNetworkSubnets(res.IPAM.Config) + res.Containers = make(map[string]EndpointResource) for _, container := range n.Containers { - res.Containers[container.ID] = EndpointResource{ + endpoint := EndpointResource{ Name: container.Labels[labels.Name], - // EndpointID: container.EndpointID, - // MacAddress: container.MacAddress, - // IPv4Address: container.IPv4Address, - // IPv6Address: container.IPv6Address, } + + if container.Process != nil && container.Process.NetNS != nil { + populateEndpointFromNetNS(&endpoint, container.Process.NetNS.Interfaces, networkSubnets) + } + + res.Containers[container.ID] = endpoint } return &res, nil