diff --git a/pkg/inspecttypes/dockercompat/dockercompat.go b/pkg/inspecttypes/dockercompat/dockercompat.go index f05f091def0..e96c40a88fa 100644 --- a/pkg/inspecttypes/dockercompat/dockercompat.go +++ b/pkg/inspecttypes/dockercompat/dockercompat.go @@ -49,6 +49,7 @@ import ( "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" "github.com/containerd/nerdctl/v2/pkg/ipcutil" "github.com/containerd/nerdctl/v2/pkg/labels" + subnetutil "github.com/containerd/nerdctl/v2/pkg/netutil/subnet" "github.com/containerd/nerdctl/v2/pkg/ocihook/state" ) @@ -1086,11 +1087,20 @@ type structuredCNI struct { Name string `json:"name"` Plugins []struct { Ipam struct { - Ranges [][]IPAMConfig `json:"ranges"` + Ranges [][]cniIPAMRange `json:"ranges"` } `json:"ipam"` } `json:"plugins"` } +// cniIPAMRange is the on-disk host-local range. Its bounds let inspect recompute +// the ip-range CIDR, which host-local has no field for. +type cniIPAMRange struct { + Subnet string `json:"subnet"` + Gateway string `json:"gateway"` + RangeStart string `json:"rangeStart"` + RangeEnd string `json:"rangeEnd"` +} + type MemorySetting struct { Limit int64 `json:"limit"` Swap int64 `json:"swap"` @@ -1195,21 +1205,35 @@ func NetworkFromNative(n *native.Network) (*Network, error) { } res.Name = sCNI.Name + // An aux-address reservation splits one subnet into several sub-ranges that + // share the subnet and gateway. Collapse each distinct subnet into a single + // IPAM.Config like Docker, keeping the first entry's gateway and its lowest + // start. host-local returns a split subnet's sub-ranges sorted, so widening the + // end as later ones arrive rebuilds the original allocation window. + idxBySubnet := make(map[string]int) + startBySubnet := make(map[string]string) for _, plugin := range sCNI.Plugins { for _, ranges := range plugin.Ipam.Ranges { - // A range-set normally describes one subnet; an aux-address - // reservation splits it into several sub-ranges that all share the - // subnet and gateway. Report the first entry per distinct subnet so a - // split subnet collapses to one IPAM.Config like Docker, without - // dropping entries for different subnets in the same set. The - // aux-addresses themselves are attached later from a nerdctl label. - seen := make(map[string]struct{}, len(ranges)) for _, r := range ranges { - if _, ok := seen[r.Subnet]; ok { + idx, ok := idxBySubnet[r.Subnet] + if !ok { + idx = len(res.IPAM.Config) + idxBySubnet[r.Subnet] = idx + startBySubnet[r.Subnet] = r.RangeStart + res.IPAM.Config = append(res.IPAM.Config, IPAMConfig{Subnet: r.Subnet, Gateway: r.Gateway}) + } + // host-local has no ipRange field, so recompute it from the outermost + // bounds the way Docker reports it. A window that spans the whole + // subnet means no --ip-range was set, so report none. The + // aux-addresses themselves are attached later from a nerdctl label. + if r.RangeEnd == "" { continue } - seen[r.Subnet] = struct{}{} - res.IPAM.Config = append(res.IPAM.Config, r) + ipRange := subnetutil.CIDRFromRange(startBySubnet[r.Subnet], r.RangeEnd) + if ipRange == r.Subnet { + ipRange = "" + } + res.IPAM.Config[idx].IPRange = ipRange } } } diff --git a/pkg/inspecttypes/dockercompat/dockercompat_test.go b/pkg/inspecttypes/dockercompat/dockercompat_test.go index 17c21f6155c..be14a0af99d 100644 --- a/pkg/inspecttypes/dockercompat/dockercompat_test.go +++ b/pkg/inspecttypes/dockercompat/dockercompat_test.go @@ -949,3 +949,39 @@ func TestImageFromNative(t *testing.T) { } }) } + +func TestNetworkFromNativeIPRange(t *testing.T) { + // host-local stores only rangeStart/rangeEnd; inspect must recompute the + // --ip-range CIDR from them and report it under IPRange like Docker, while a + // subnet without an ip-range reports no IPRange. + cni := `{"name":"testnet","plugins":[{"ipam":{"ranges":[` + + `[{"subnet":"172.28.0.0/16","gateway":"172.28.5.254","rangeStart":"172.28.5.1","rangeEnd":"172.28.5.255"}],` + + `[{"subnet":"10.9.0.0/24","gateway":"10.9.0.1"}]` + + `]}}]}` + got, err := NetworkFromNative(&native.Network{CNI: []byte(cni)}) + assert.NilError(t, err) + assert.DeepEqual(t, []IPAMConfig{ + {Subnet: "172.28.0.0/16", Gateway: "172.28.5.254", IPRange: "172.28.5.0/24"}, + {Subnet: "10.9.0.0/24", Gateway: "10.9.0.1"}, + }, got.IPAM.Config) +} + +func TestNetworkFromNativeIPRangeSplit(t *testing.T) { + // An aux-address reservation splits a subnet into sorted sub-ranges on disk. + // inspect must collapse them to one IPAM.Config and rebuild the ip-range from + // the outermost bounds: the first subnet reconstructs its original --ip-range, + // while the second spans its whole subnet (aux-address only, no --ip-range) and + // so reports no IPRange. + cni := `{"name":"testnet","plugins":[{"ipam":{"ranges":[` + + `[{"subnet":"172.28.0.0/16","gateway":"172.28.5.254","rangeStart":"172.28.5.1","rangeEnd":"172.28.5.9"},` + + `{"subnet":"172.28.0.0/16","rangeStart":"172.28.5.11","rangeEnd":"172.28.5.255"}],` + + `[{"subnet":"10.9.0.0/24","gateway":"10.9.0.1","rangeStart":"10.9.0.1","rangeEnd":"10.9.0.4"},` + + `{"subnet":"10.9.0.0/24","rangeStart":"10.9.0.6","rangeEnd":"10.9.0.254"}]` + + `]}}]}` + got, err := NetworkFromNative(&native.Network{CNI: []byte(cni)}) + assert.NilError(t, err) + assert.DeepEqual(t, []IPAMConfig{ + {Subnet: "172.28.0.0/16", Gateway: "172.28.5.254", IPRange: "172.28.5.0/24"}, + {Subnet: "10.9.0.0/24", Gateway: "10.9.0.1"}, + }, got.IPAM.Config) +} diff --git a/pkg/netutil/cni_plugin.go b/pkg/netutil/cni_plugin.go index b44e76042e2..4f5ad91c368 100644 --- a/pkg/netutil/cni_plugin.go +++ b/pkg/netutil/cni_plugin.go @@ -33,7 +33,6 @@ type IPAMRange struct { RangeStart string `json:"rangeStart,omitempty"` RangeEnd string `json:"rangeEnd,omitempty"` Gateway string `json:"gateway,omitempty"` - IPRange string `json:"ipRange,omitempty"` } type IPAMRoute struct { diff --git a/pkg/netutil/netutil.go b/pkg/netutil/netutil.go index b849ac17e81..56950e26cd8 100644 --- a/pkg/netutil/netutil.go +++ b/pkg/netutil/netutil.go @@ -627,9 +627,9 @@ func parseIPAMRange(subnet *net.IPNet, gatewayStr, ipRangeStr string) (*IPAMRang if !subnet.Contains(rangeStart) || !subnet.Contains(rangeEnd) { return nil, fmt.Errorf("no matching subnet %q for ip-range %q", subnet, ipRangeStr) } + // host-local has no ipRange field; store the bounds and recompute on inspect. res.RangeStart = rangeStart.String() res.RangeEnd = rangeEnd.String() - res.IPRange = ipRangeStr } return res, nil @@ -664,9 +664,9 @@ func ParseAuxAddresses(raw []string) (map[string]string, error) { // carving them out. host-local has no exclude list, but it does allocate across // every range in a set, so the reserved IPs become gaps between sub-ranges and // are never handed out. Reserved IPs outside the allocation window need no split -// (host-local cannot reach them anyway). The base range's gateway and ip-range -// are kept on the first sub-range so the rest of the pipeline and `network -// inspect` behave exactly as the un-split case. +// (host-local cannot reach them anyway). The base range's gateway is kept on +// every sub-range; inspect rebuilds the original ip-range from the outermost +// sub-range bounds, so nothing else has to be carried across the split. func splitIPAMRange(subnet *net.IPNet, base *IPAMRange, reserved []net.IP) ([]IPAMRange, error) { if len(reserved) == 0 { return []IPAMRange{*base}, nil @@ -736,12 +736,10 @@ func splitIPAMRange(subnet *net.IPNet, base *IPAMRange, reserved []net.IP) ([]IP // host-local reserves the gateway only when it is set on the range it lands // in, and after splitting the gateway can be in any sub-range, so set it on - // all of them. The original ip-range is nerdctl-only bookkeeping for inspect, - // so keep it on the first sub-range alone. + // all of them. for i := range out { out[i].Gateway = base.Gateway } - out[0].IPRange = base.IPRange return out, nil } diff --git a/pkg/netutil/netutil_test.go b/pkg/netutil/netutil_test.go index e7c60d98ce1..caf2c6f0522 100644 --- a/pkg/netutil/netutil_test.go +++ b/pkg/netutil/netutil_test.go @@ -104,7 +104,6 @@ func TestParseIPAMRange(t *testing.T) { expected: &IPAMRange{ Subnet: "10.1.0.0/16", Gateway: "10.1.0.1", - IPRange: "10.1.100.0/24", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.255", }, @@ -115,7 +114,6 @@ func TestParseIPAMRange(t *testing.T) { expected: &IPAMRange{ Subnet: "10.1.100.0/23", Gateway: "10.1.100.1", - IPRange: "10.1.100.0/25", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.127", }, @@ -245,20 +243,20 @@ func TestSplitIPAMRange(t *testing.T) { { name: "a reservation inside an ip-range splits within its bounds", subnet: "10.1.100.0/24", - base: &IPAMRange{Subnet: "10.1.100.0/24", Gateway: "10.1.100.1", IPRange: "10.1.100.0/28", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.15"}, + base: &IPAMRange{Subnet: "10.1.100.0/24", Gateway: "10.1.100.1", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.15"}, reserved: ips("10.1.100.5"), expected: []IPAMRange{ - {Subnet: "10.1.100.0/24", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.4", Gateway: "10.1.100.1", IPRange: "10.1.100.0/28"}, + {Subnet: "10.1.100.0/24", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.4", Gateway: "10.1.100.1"}, {Subnet: "10.1.100.0/24", RangeStart: "10.1.100.6", RangeEnd: "10.1.100.15", Gateway: "10.1.100.1"}, }, }, { name: "a reservation outside the ip-range needs no split", subnet: "10.1.100.0/24", - base: &IPAMRange{Subnet: "10.1.100.0/24", Gateway: "10.1.100.1", IPRange: "10.1.100.0/28", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.15"}, + base: &IPAMRange{Subnet: "10.1.100.0/24", Gateway: "10.1.100.1", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.15"}, reserved: ips("10.1.100.200"), expected: []IPAMRange{ - {Subnet: "10.1.100.0/24", Gateway: "10.1.100.1", IPRange: "10.1.100.0/28", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.15"}, + {Subnet: "10.1.100.0/24", Gateway: "10.1.100.1", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.15"}, }, }, { diff --git a/pkg/netutil/netutil_unix_test.go b/pkg/netutil/netutil_unix_test.go index 1d6ce4a59a2..ada8bb1566a 100644 --- a/pkg/netutil/netutil_unix_test.go +++ b/pkg/netutil/netutil_unix_test.go @@ -95,12 +95,14 @@ func TestPairIPAMRangesIPRange(t *testing.T) { ranges, findIPv4, _, err := pairIPAMRanges(subnets, nil, ipRanges, nil, true) assert.NilError(t, err) assert.Equal(t, true, findIPv4) + // The ip-range is no longer stored verbatim; its effect shows up as the + // rangeStart/rangeEnd bounds host-local actually uses. got := map[string]string{} for _, r := range ranges { - got[r[0].Subnet] = r[0].IPRange + got[r[0].Subnet] = r[0].RangeStart } - assert.Equal(t, "10.6.1.0/24", got["10.6.0.0/16"]) - assert.Equal(t, "2001:db8:6::/80", got["2001:db8:6::/64"]) + assert.Equal(t, "10.6.1.1", got["10.6.0.0/16"]) + assert.Equal(t, "2001:db8:6::1", got["2001:db8:6::/64"]) }) t.Run("an ip-range matching no subnet errors", func(t *testing.T) { diff --git a/pkg/netutil/subnet/subnet.go b/pkg/netutil/subnet/subnet.go index 190c7dd4f81..23f44306b44 100644 --- a/pkg/netutil/subnet/subnet.go +++ b/pkg/netutil/subnet/subnet.go @@ -134,3 +134,43 @@ func FirstIPInSubnet(addr *net.IPNet) (net.IP, error) { cidr.IP[len(cidr.IP)-1]++ return cidr.IP, nil } + +// CIDRFromRange inverts FirstIPInSubnet/LastIPInSubnet: it rebuilds the CIDR from +// the start and end they produced. Returns "" for empty or unparsable bounds. +// A /31 or /127 has no distinct network and broadcast, so it recomputes as /32 or /128. +func CIDRFromRange(startStr, endStr string) string { + if startStr == "" || endStr == "" { + return "" + } + start, end := net.ParseIP(startStr), net.ParseIP(endStr) + if start == nil || end == nil { + return "" + } + // A single-address range is a /32 or /128. + if start.Equal(end) { + if start.To4() != nil { + return start.String() + "/32" + } + return start.String() + "/128" + } + // Canonical byte form: 4 for v4, 16 for v6. + s, e, bits := start.To4(), end.To4(), 32 + if s == nil { + s, e, bits = start.To16(), end.To16(), 128 + } + if e == nil || len(s) != len(e) { + return "" + } + // Undo FirstIPInSubnet's last-byte bump to get the network. + network := make(net.IP, len(s)) + copy(network, s) + network[len(network)-1]-- + // end is the broadcast, so network^end is the host mask; its width is the host bits. + hostBits := 0 + for i := range network { + for b := network[i] ^ e[i]; b != 0; b >>= 1 { + hostBits++ + } + } + return fmt.Sprintf("%s/%d", network.String(), bits-hostBits) +} diff --git a/pkg/netutil/subnet/subnet_test.go b/pkg/netutil/subnet/subnet_test.go index f61e719588e..cae38ca430f 100644 --- a/pkg/netutil/subnet/subnet_test.go +++ b/pkg/netutil/subnet/subnet_test.go @@ -48,3 +48,31 @@ func TestNextSubnet(t *testing.T) { assert.Equal(t, nextSubnet.String(), tc.expect) } } + +func TestCIDRFromRange(t *testing.T) { + testCases := []struct { + name string + start, end string + expect string + }{ + {"no range", "", "", ""}, + {"v4 /24", "10.1.100.1", "10.1.100.255", "10.1.100.0/24"}, + {"v4 /25", "10.24.24.1", "10.24.24.127", "10.24.24.0/25"}, + {"v4 /16", "172.28.0.1", "172.28.255.255", "172.28.0.0/16"}, + {"v4 offset /25", "10.1.100.129", "10.1.100.255", "10.1.100.128/25"}, + {"v4 /32", "10.0.0.5", "10.0.0.5", "10.0.0.5/32"}, + {"v4 /31 collapses to /32", "10.0.0.1", "10.0.0.1", "10.0.0.1/32"}, + {"v6 /64", "fd00:55::1", "fd00:55::ffff:ffff:ffff:ffff", "fd00:55::/64"}, + {"v6 /120", "fd00:7::1", "fd00:7::ff", "fd00:7::/120"}, + {"v6 /128", "fd00::5", "fd00::5", "fd00::5/128"}, + {"v6 /127 collapses to /128", "fd00::1", "fd00::1", "fd00::1/128"}, + {"start unparsable", "bogus", "10.0.0.255", ""}, + {"end unparsable", "10.0.0.1", "bogus", ""}, + {"mismatched families", "10.0.0.1", "fd00::ff", ""}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expect, CIDRFromRange(tc.start, tc.end)) + }) + } +}