diff --git a/cmd/nerdctl/network/network_inspect_test.go b/cmd/nerdctl/network/network_inspect_test.go index 3b7e5276420..b4fcc690825 100644 --- a/cmd/nerdctl/network/network_inspect_test.go +++ b/cmd/nerdctl/network/network_inspect_test.go @@ -397,6 +397,64 @@ 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")) + + // Test IPv4Address has CIDR notation (not empty and contains '/') + assert.Assert(t, container.IPv4Address != "", "IPv4Address should not be empty") + assert.Assert(t, strings.Contains(container.IPv4Address, "/"), "IPv4Address should contain CIDR notation with /") + + // 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) diff --git a/pkg/inspecttypes/dockercompat/dockercompat.go b/pkg/inspecttypes/dockercompat/dockercompat.go index 5ebfb0c2980..07305a4ecdf 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,88 @@ 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 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 { + 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) { + endpoint.MacAddress = iface.HardwareAddr + setIPAddresses(endpoint, addr) + return true + } + } + } + return false +} + +// 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 +1055,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