diff --git a/cmd/nerdctl/container/container_inspect_linux_test.go b/cmd/nerdctl/container/container_inspect_linux_test.go index 1c82925bf46..7d799ca127e 100644 --- a/cmd/nerdctl/container/container_inspect_linux_test.go +++ b/cmd/nerdctl/container/container_inspect_linux_test.go @@ -29,6 +29,7 @@ import ( "github.com/containerd/continuity/testutil/loopback" "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" "github.com/containerd/nerdctl/mod/tigron/tig" @@ -586,6 +587,61 @@ USER test testCase.Run(t) } +func TestContainerInspectGateway(t *testing.T) { + testCase := nerdtest.Setup() + + // This test validates nerdctl's inspect conversion path + // Running this against Docker would not use this code path + testCase.Require = require.All( + require.Not(require.Windows), + require.Not(nerdtest.Docker), + nerdtest.Rootful, + ) + + testCase.NoParallel = true + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", data.Identifier("net")) + + helpers.Ensure("run", "-d", + "--name", data.Identifier("ctr"), + "--network", data.Identifier("net"), + testutil.CommonImage, "sleep", nerdtest.Infinity) + + nerdtest.EnsureContainerStarted(helpers, data.Identifier("ctr")) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("ctr")) + helpers.Anyhow("network", "rm", data.Identifier("net")) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "inspect", data.Identifier("ctr")) + } + + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: func(stdout string, t tig.T) { + var containers []dockercompat.Container + err := json.Unmarshal([]byte(stdout), &containers) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(containers), "Unexpectedly got multiple results\n") + + assert.Assert(t, containers[0].NetworkSettings != nil) + assert.Assert(t, containers[0].NetworkSettings.Gateway != "") + + network := nerdtest.InspectNetwork(helpers, data.Identifier("net")) + assert.Assert(t, len(network.IPAM.Config) > 0) + assert.Equal(t, network.IPAM.Config[0].Gateway, containers[0].NetworkSettings.Gateway) + }, + } + } + + testCase.Run(t) +} + type hostConfigValues struct { Driver string ShmSize int64 diff --git a/pkg/containerinspector/containerinspector_linux.go b/pkg/containerinspector/containerinspector_linux.go index 729f878b764..41d83dbd0b0 100644 --- a/pkg/containerinspector/containerinspector_linux.go +++ b/pkg/containerinspector/containerinspector_linux.go @@ -23,6 +23,7 @@ import ( "strings" "github.com/containernetworking/plugins/pkg/ns" + "github.com/vishvananda/netlink" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" ) @@ -55,6 +56,10 @@ func InspectNetNS(ctx context.Context, pid int) (*native.NetNS, error) { res.Interfaces[i] = x } res.PrimaryInterface = determinePrimaryInterface(res.Interfaces) + routes, err := netlink.RouteList(nil, netlink.FAMILY_V4) + if err == nil { + res.Gateway = selectDefaultGateway(routes, res.PrimaryInterface) + } return nil } if err := ns.WithNetNSPath(nsPath, fn); err != nil { @@ -73,3 +78,32 @@ func determinePrimaryInterface(interfaces []native.NetInterface) int { } return 0 } + +// isDefaultRoute reports whether route is a default route. +// netlink synthesizes a 0.0.0.0/0 Dst for default routes rather than leaving it +// nil, so the prefix length is what distinguishes them. +func isDefaultRoute(route netlink.Route) bool { + if route.Dst == nil { + return true + } + ones, _ := route.Dst.Mask.Size() + return ones == 0 +} + +// selectDefaultGateway returns the IPv4 default gateway, preferring the route +// that leaves through the primary interface. Returns "" when there is none. +func selectDefaultGateway(routes []netlink.Route, primaryIfIndex int) string { + fallback := "" + for _, route := range routes { + if !isDefaultRoute(route) || route.Gw == nil || route.Gw.To4() == nil { + continue + } + if route.LinkIndex == primaryIfIndex { + return route.Gw.String() + } + if fallback == "" { + fallback = route.Gw.String() + } + } + return fallback +} diff --git a/pkg/containerinspector/containerinspector_linux_test.go b/pkg/containerinspector/containerinspector_linux_test.go new file mode 100644 index 00000000000..79006e19d78 --- /dev/null +++ b/pkg/containerinspector/containerinspector_linux_test.go @@ -0,0 +1,101 @@ +/* + Copyright The containerd 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 containerinspector + +import ( + "net" + "testing" + + "github.com/vishvananda/netlink" + "gotest.tools/v3/assert" +) + +// defaultRoute mimics what netlink returns for a default route: Dst is not nil, +// it is synthesized as 0.0.0.0/0. +func defaultRoute(linkIndex int, gw string) netlink.Route { + return netlink.Route{ + LinkIndex: linkIndex, + Dst: &net.IPNet{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)}, + Gw: net.ParseIP(gw), + } +} + +func subnetRoute(linkIndex int, cidr string) netlink.Route { + _, dst, _ := net.ParseCIDR(cidr) + return netlink.Route{LinkIndex: linkIndex, Dst: dst} +} + +func TestSelectDefaultGateway(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + routes []netlink.Route + primary int + expected string + }{ + { + name: "default route on the primary interface", + routes: []netlink.Route{subnetRoute(2, "10.4.1.0/24"), defaultRoute(2, "10.4.1.1")}, + primary: 2, + expected: "10.4.1.1", + }, + { + name: "prefers the primary interface over another default route", + routes: []netlink.Route{defaultRoute(3, "192.168.0.1"), defaultRoute(2, "10.4.1.1")}, + primary: 2, + expected: "10.4.1.1", + }, + { + name: "falls back when no default route matches the primary interface", + routes: []netlink.Route{defaultRoute(3, "192.168.0.1")}, + primary: 2, + expected: "192.168.0.1", + }, + { + name: "nil Dst is still treated as a default route", + routes: []netlink.Route{{LinkIndex: 2, Gw: net.ParseIP("10.4.1.1")}}, + primary: 2, + expected: "10.4.1.1", + }, + { + name: "no default route", + routes: []netlink.Route{subnetRoute(2, "10.4.1.0/24")}, + primary: 2, + expected: "", + }, + { + name: "onlink default route without a gateway is skipped", + routes: []netlink.Route{{LinkIndex: 2, Dst: &net.IPNet{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)}}}, + primary: 2, + expected: "", + }, + { + name: "IPv6 gateway is ignored", + routes: []netlink.Route{{LinkIndex: 2, Gw: net.ParseIP("fe80::1")}}, + primary: 2, + expected: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, selectDefaultGateway(tc.routes, tc.primary)) + }) + } +} diff --git a/pkg/inspecttypes/dockercompat/dockercompat.go b/pkg/inspecttypes/dockercompat/dockercompat.go index 5ebfb0c2980..bd4b55ca995 100644 --- a/pkg/inspecttypes/dockercompat/dockercompat.go +++ b/pkg/inspecttypes/dockercompat/dockercompat.go @@ -281,7 +281,7 @@ type CPUSettings struct { // DefaultNetworkSettings is from https://github.com/moby/moby/blob/v20.10.1/api/types/types.go#L405-L414 type DefaultNetworkSettings struct { // TODO EndpointID string // EndpointID uniquely represents a service endpoint in a Sandbox - // TODO Gateway string // Gateway holds the gateway address for the network + Gateway string // Gateway holds the gateway address for the network GlobalIPv6Address string // GlobalIPv6Address holds network's global IPv6 address GlobalIPv6PrefixLen int // GlobalIPv6PrefixLen represents mask length of network's global IPv6 address IPAddress string // IPAddress holds the IPv4 address for the network @@ -743,6 +743,7 @@ func networkSettingsFromNative(n *native.NetNS, _ *specs.Spec) (*NetworkSettings } } + res.DefaultNetworkSettings.Gateway = n.Gateway if primary != nil { res.DefaultNetworkSettings.MacAddress = primary.MacAddress res.DefaultNetworkSettings.IPAddress = primary.IPAddress diff --git a/pkg/inspecttypes/dockercompat/dockercompat_test.go b/pkg/inspecttypes/dockercompat/dockercompat_test.go index a4dbec2d4f3..2c9896566b3 100644 --- a/pkg/inspecttypes/dockercompat/dockercompat_test.go +++ b/pkg/inspecttypes/dockercompat/dockercompat_test.go @@ -401,6 +401,7 @@ func TestNetworkSettingsFromNative(t *testing.T) { { name: "Given NetNS with single Interface with Port Annotation, Return populated NetworkSettings", n: &native.NetNS{ + Gateway: "10.0.4.1", Interfaces: []native.NetInterface{ { Interface: net.Interface{ @@ -427,6 +428,9 @@ func TestNetworkSettingsFromNative(t *testing.T) { Annotations: map[string]string{}, }, expected: &NetworkSettings{ + DefaultNetworkSettings: DefaultNetworkSettings{ + Gateway: "10.0.4.1", + }, Ports: &nat.PortMap{ nat.Port("77/tcp"): []nat.PortBinding{ { @@ -449,6 +453,7 @@ func TestNetworkSettingsFromNative(t *testing.T) { { name: "Given NetNS with single Interface without Port Annotations, Return valid NetworkSettings w/ empty Ports", n: &native.NetNS{ + Gateway: "10.0.4.1", Interfaces: []native.NetInterface{ { Interface: net.Interface{ @@ -467,6 +472,9 @@ func TestNetworkSettingsFromNative(t *testing.T) { Annotations: map[string]string{}, }, expected: &NetworkSettings{ + DefaultNetworkSettings: DefaultNetworkSettings{ + Gateway: "10.0.4.1", + }, Ports: &nat.PortMap{}, Networks: map[string]*NetworkEndpointSettings{ "unknown-eth0.100": { diff --git a/pkg/inspecttypes/native/container.go b/pkg/inspecttypes/native/container.go index 1bd421a2d62..b30fa6a68c4 100644 --- a/pkg/inspecttypes/native/container.go +++ b/pkg/inspecttypes/native/container.go @@ -44,6 +44,7 @@ type NetNS struct { // Zero means unset. PrimaryInterface int `json:"PrimaryInterface,omitempty"` Interfaces []NetInterface `json:"Interfaces,omitempty"` + Gateway string `json:"Gateway,omitempty"` PortMappings []cni.PortMapping }