api,daemon: report IPAM status for network

On API v1.52 and newer, the GET /networks/{id} endpoint returns
statistics about the IPAM state for the subnets assigned to the network.

Signed-off-by: Cory Snider <csnider@mirantis.com>
This commit is contained in:
Cory Snider
2025-09-08 16:43:29 -04:00
parent ee8abb845d
commit 3f86797d3f
24 changed files with 857 additions and 9 deletions

View File

@@ -2574,6 +2574,21 @@ definitions:
type: ServiceInfo
hints:
nullable: false
Status:
description: >
provides runtime information about the network
such as the number of allocated IPs.
$ref: "#/definitions/NetworkStatus"
NetworkStatus:
description: >
provides runtime information about the network
such as the number of allocated IPs.
type: "object"
x-go-name: Status
properties:
IPAM:
$ref: "#/definitions/IPAMStatus"
ServiceInfo:
x-nullable: false
@@ -2685,6 +2700,46 @@ definitions:
additionalProperties:
type: "string"
IPAMStatus:
type: "object"
x-nullable: false
x-omitempty: false
properties:
Subnets:
type: "object"
additionalProperties:
$ref: "#/definitions/SubnetStatus"
example:
"172.16.0.0/16":
IPsInUse: 3
DynamicIPsAvailable: 65533
"2001:db8:abcd:0012::0/96":
IPsInUse: 5
DynamicIPsAvailable: 4294967291
x-go-type:
type: SubnetStatuses
kind: map
SubnetStatus:
type: "object"
x-nullable: false
x-omitempty: false
properties:
IPsInUse:
description: >
Number of IP addresses in the subnet that are in use or reserved and
are therefore unavailable for allocation, saturating at 2<sup>64</sup> - 1.
type: integer
format: uint64
x-omitempty: false
DynamicIPsAvailable:
description: >
Number of IP addresses within the network's IPRange for the subnet
that are available for allocation, saturating at 2<sup>64</sup> - 1.
type: integer
format: uint64
x-omitempty: false
EndpointResource:
type: "object"
description: >

View File

@@ -20,4 +20,8 @@ type Inspect struct {
// swarm scope networks, and omitted for local scope networks.
//
Services map[string]ServiceInfo `json:"Services,omitempty"`
// provides runtime information about the network such as the number of allocated IPs.
//
Status *Status `json:"Status,omitempty"`
}

View File

@@ -22,6 +22,8 @@ type IPAMConfig struct {
AuxAddress map[string]string `json:"AuxiliaryAddresses,omitempty"`
}
type SubnetStatuses = map[netip.Prefix]SubnetStatus
type ipFamily string
const (

View File

@@ -0,0 +1,16 @@
// Code generated by go-swagger; DO NOT EDIT.
package network
// This file was generated by the swagger tool.
// Editing this file might prove futile when you re-run the swagger generate command
// IPAMStatus IPAM status
//
// swagger:model IPAMStatus
type IPAMStatus struct {
// subnets
// Example: {"172.16.0.0/16":{"DynamicIPsAvailable":65533,"IPsInUse":3},"2001:db8:abcd:0012::0/96":{"DynamicIPsAvailable":4294967291,"IPsInUse":5}}
Subnets SubnetStatuses `json:"Subnets,omitempty"`
}

View File

@@ -0,0 +1,15 @@
// Code generated by go-swagger; DO NOT EDIT.
package network
// This file was generated by the swagger tool.
// Editing this file might prove futile when you re-run the swagger generate command
// Status provides runtime information about the network such as the number of allocated IPs.
//
// swagger:model Status
type Status struct {
// IPAM
IPAM IPAMStatus `json:"IPAM"`
}

View File

@@ -0,0 +1,20 @@
// Code generated by go-swagger; DO NOT EDIT.
package network
// This file was generated by the swagger tool.
// Editing this file might prove futile when you re-run the swagger generate command
// SubnetStatus subnet status
//
// swagger:model SubnetStatus
type SubnetStatus struct {
// Number of IP addresses in the subnet that are in use or reserved and are therefore unavailable for allocation, saturating at 2<sup>64</sup> - 1.
//
IPsInUse uint64 `json:"IPsInUse"`
// Number of IP addresses within the network's IPRange for the subnet that are available for allocation, saturating at 2<sup>64</sup> - 1.
//
DynamicIPsAvailable uint64 `json:"DynamicIPsAvailable"`
}

View File

@@ -66,3 +66,10 @@ func (x Uint128) Fill16(a *[16]byte) {
func (x Uint128) Uint64() uint64 {
return x.lo
}
func (x Uint128) Uint64Sat() uint64 {
if x.hi != 0 {
return ^uint64(0)
}
return x.lo
}

View File

@@ -5,6 +5,7 @@ import (
"net"
"net/netip"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/v2/daemon/libnetwork/types"
)
@@ -57,6 +58,12 @@ type Ipam interface {
IsBuiltIn() bool
}
type PoolStatuser interface {
Ipam
// Status returns the operational status of the specified IPAM pool.
PoolStatus(poolID string) (network.SubnetStatus, error)
}
type PoolRequest struct {
// AddressSpace is a mandatory field which denotes which block of pools
// should be used to make the allocation. This value is opaque, and only

View File

@@ -7,7 +7,9 @@ import (
"sync"
"github.com/containerd/log"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/v2/daemon/libnetwork/internal/netiputil"
"github.com/moby/moby/v2/daemon/libnetwork/internal/uint128"
"github.com/moby/moby/v2/daemon/libnetwork/ipamapi"
"github.com/moby/moby/v2/daemon/libnetwork/ipamutils"
"github.com/moby/moby/v2/daemon/libnetwork/ipbits"
@@ -369,3 +371,24 @@ func (aSpace *addrSpace) releaseAddress(nw, sub netip.Prefix, address netip.Addr
return p.addrs.Remove(address)
}
func (aSpace *addrSpace) allocationStatus(nw, ipr netip.Prefix) (network.SubnetStatus, error) {
aSpace.mu.Lock()
defer aSpace.mu.Unlock()
if ipr == (netip.Prefix{}) {
ipr = nw
}
p, ok := aSpace.subnets[nw]
if !ok {
return network.SubnetStatus{}, types.NotFoundErrorf("cannot find address pool for %v", nw)
}
iprcap := uint128.From(0, 1).Lsh(uint(ipr.Addr().BitLen() - ipr.Bits()))
ipralloc := uint128.From(p.addrs.AddrsInPrefix(ipr))
return network.SubnetStatus{
IPsInUse: uint128.From(p.addrs.Len()).Uint64Sat(),
DynamicIPsAvailable: iprcap.Sub(ipralloc).Uint64Sat(),
}, nil
}

View File

@@ -8,6 +8,7 @@ import (
"net/netip"
"github.com/containerd/log"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/v2/daemon/libnetwork/internal/addrset"
"github.com/moby/moby/v2/daemon/libnetwork/internal/netiputil"
"github.com/moby/moby/v2/daemon/libnetwork/ipamapi"
@@ -23,6 +24,8 @@ const (
globalAddressSpace = "GlobalDefault"
)
var _ ipamapi.PoolStatuser = &Allocator{}
// Register registers the default ipam driver with libnetwork. It takes
// two optional address pools respectively containing the list of user-defined
// address pools for 'local' and 'global' address spaces.
@@ -312,6 +315,21 @@ func getAddress(base netip.Prefix, addrSet *addrset.AddrSet, prefAddress netip.A
return addr, nil
}
// PoolStatus returns the operational status of the specified IPAM pool.
func (a *Allocator) PoolStatus(poolID string) (network.SubnetStatus, error) {
k, err := PoolIDFromString(poolID)
if err != nil {
return network.SubnetStatus{}, types.InvalidParameterErrorf("invalid pool id: %s", poolID)
}
aSpace, err := a.getAddrSpace(k.AddressSpace, k.Is6())
if err != nil {
return network.SubnetStatus{}, err
}
return aSpace.allocationStatus(k.Subnet, k.ChildSubnet)
}
// IsBuiltIn returns true for builtin drivers
func (a *Allocator) IsBuiltIn() bool {
return true

View File

@@ -9,12 +9,14 @@ import (
"net"
"net/netip"
"runtime"
"slices"
"strings"
"sync"
"time"
cerrdefs "github.com/containerd/errdefs"
"github.com/containerd/log"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/v2/daemon/internal/sliceutil"
"github.com/moby/moby/v2/daemon/internal/stringid"
"github.com/moby/moby/v2/daemon/libnetwork/datastore"
@@ -30,6 +32,7 @@ import (
"github.com/moby/moby/v2/daemon/libnetwork/scope"
"github.com/moby/moby/v2/daemon/libnetwork/types"
"github.com/moby/moby/v2/errdefs"
"github.com/moby/moby/v2/internal/iterutil"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
@@ -2171,3 +2174,37 @@ func (n *Network) deleteLoadBalancerSandbox() error {
}
return nil
}
func (n *Network) IPAMStatus(ctx context.Context) (network.IPAMStatus, error) {
status := network.IPAMStatus{
Subnets: make(map[netip.Prefix]network.SubnetStatus),
}
if n.hasSpecialDriver() {
// Special drivers do not assign addresses from IPAM
return status, nil
}
ipamdriver, _, err := n.getController().getIPAMDriver(n.ipamType)
if err != nil {
return status, err
}
ipam, ok := ipamdriver.(ipamapi.PoolStatuser)
if !ok {
return status, nil
}
var errs []error
info4, info6 := n.IpamInfo()
for info := range iterutil.Chain(slices.Values(info4), slices.Values(info6)) {
pstat, err := ipam.PoolStatus(info.PoolID)
if err != nil {
errs = append(errs, fmt.Errorf("failed to retrieve pool %s status: %w", info.PoolID, err))
continue
}
prefix, _ := netiputil.ToPrefix(info.Pool)
status.Subnets[prefix] = pstat
}
return status, errors.Join(errs...)
}

View File

@@ -602,6 +602,19 @@ func (daemon *Daemon) GetNetworks(filter network.Filter, config backend.NetworkL
if config.WithServices {
nr.Services = buildServiceAttachments(n)
}
if config.WithStatus {
ipam, err := n.IPAMStatus(context.TODO())
if err != nil {
log.G(context.TODO()).WithFields(log.Fields{
"network": n.Name(),
"id": n.ID(),
"error": err,
}).Warning("Error encountered while gathering IPAM status for network")
}
nr.Status = &networktypes.Status{
IPAM: ipam,
}
}
networks = append(networks, nr)
}
}

View File

@@ -215,4 +215,5 @@ type PluginDisableConfig struct {
// NetworkListConfig stores the options available for listing networks
type NetworkListConfig struct {
WithServices bool
WithStatus bool
}

View File

@@ -146,7 +146,10 @@ func (n *networkRouter) getNetwork(ctx context.Context, w http.ResponseWriter, r
}
filter.IDAlsoMatchesName = true
networks, _ := n.backend.GetNetworks(filter, backend.NetworkListConfig{WithServices: verbose})
networks, _ := n.backend.GetNetworks(filter, backend.NetworkListConfig{
WithServices: verbose,
WithStatus: versions.GreaterThanOrEqualTo(httputils.VersionFromContext(ctx), "1.52"),
})
for _, nw := range networks {
if nw.ID == term {
return httputils.WriteJSON(w, http.StatusOK, nw)

View File

@@ -60,15 +60,18 @@ EOT
# TODO: Restore when go-swagger is updated
# See https://github.com/moby/moby/pull/47526#discussion_r1551800022
generate_model types/network --keep-spec-order <<- 'EOT'
generate_model types/network --keep-spec-order --additional-initialism=IPAM <<- 'EOT'
ConfigReference
EndpointResource
IPAMStatus
Network
NetworkCreateResponse
NetworkInspect
NetworkStatus
NetworkSummary
NetworkTaskInfo
PeerInfo
SubnetStatus
EOT
generate_model types/plugin <<- 'EOT'

View File

@@ -137,16 +137,20 @@ func WithIPAM(subnet, gateway string) func(*client.NetworkCreateOptions) {
// WithIPAMRange adds an IPAM with the specified Subnet, IPRange and Gateway to the network
func WithIPAMRange(subnet, iprange, gateway string) func(*client.NetworkCreateOptions) {
return WithIPAMConfig(network.IPAMConfig{
Subnet: subnet,
IPRange: iprange,
Gateway: gateway,
AuxAddress: map[string]string{},
})
}
// WithIPAMConfig adds the provided IPAM configurations to the network
func WithIPAMConfig(configs ...network.IPAMConfig) func(*client.NetworkCreateOptions) {
return func(n *client.NetworkCreateOptions) {
if n.IPAM == nil {
n.IPAM = &network.IPAM{}
}
n.IPAM.Config = append(n.IPAM.Config, network.IPAMConfig{
Subnet: subnet,
IPRange: iprange,
Gateway: gateway,
AuxAddress: map[string]string{},
})
n.IPAM.Config = append(n.IPAM.Config, configs...)
}
}

View File

@@ -3,6 +3,7 @@ package bridge
import (
"context"
"fmt"
"math"
"net"
"net/netip"
"strings"
@@ -1064,3 +1065,154 @@ func TestPortBindingBackfillingForOlderContainers(t *testing.T) {
}}
assert.DeepEqual(t, expMappings, inspect.HostConfig.PortBindings)
}
func TestBridgeIPAMStatus(t *testing.T) {
ctx := testutil.StartSpan(baseContext, t)
d := daemon.New(t)
d.StartWithBusybox(ctx, t)
defer d.Stop(t)
c := d.NewClientT(t, client.WithVersion("1.52"))
checkSubnets := func(
netName string, want networktypes.SubnetStatuses) bool {
t.Helper()
nw, err := c.NetworkInspect(ctx, netName, client.NetworkInspectOptions{})
if assert.Check(t, err) && assert.Check(t, nw.Status != nil) {
return assert.Check(t, is.DeepEqual(want, nw.Status.IPAM.Subnets))
}
return false
}
t.Run("DualStack", func(t *testing.T) {
const (
netName = "testipambridge"
ipv4gw = "192.168.0.1"
ipv4Range = "192.168.0.64/31"
prefIPv4OutOfRange = "192.168.0.129"
auxIPv4FromRange = "192.168.0.65"
auxIPv4OutOfRange = "192.168.0.128"
ipv6gw = "2001:db8:abcd::1"
ipv6Range = "2001:db8:abcd::/120"
prefIPv6OutOfRange = "2001:db8:abcd::9000"
auxIPv6FromRange = "2001:db8:abcd::2a"
auxIPv6OutOfRange = "2001:db8:abcd::8000"
)
var (
cidrv4 = netip.MustParsePrefix("192.168.0.0/24")
cidrv6 = netip.MustParsePrefix("2001:db8:abcd::/64")
)
network.CreateNoError(ctx, t, c, netName,
network.WithIPv4(true),
network.WithIPAMConfig(networktypes.IPAMConfig{
Subnet: cidrv4.String(),
IPRange: ipv4Range,
Gateway: ipv4gw,
AuxAddress: map[string]string{
"reserved": auxIPv4FromRange,
"reserved_1": auxIPv4OutOfRange,
}}),
network.WithIPv6(),
network.WithIPAMConfig(networktypes.IPAMConfig{
Subnet: cidrv6.String(),
IPRange: ipv6Range,
Gateway: ipv6gw,
AuxAddress: map[string]string{
"reserved1": auxIPv6FromRange,
"reserved2": auxIPv6OutOfRange,
},
}),
)
defer c.NetworkRemove(ctx, netName)
checkSubnets(netName, map[netip.Prefix]networktypes.SubnetStatus{
cidrv4: {
// 1 subnet + 1 gateway + 1 broadcast + 2 aux addresses
IPsInUse: 5,
// IPv4 /31 IPRange (2 addresses) - aux in-range
DynamicIPsAvailable: 1,
},
cidrv6: {
IPsInUse: 4, // 1 gateway + 1 anycast + 2 aux addresses
DynamicIPsAvailable: 253, // IPv6 /120 IPRange (256 addresses) - 1 router-anycast - 1 gateway - 1 aux in-range
},
})
func() {
// From IPRange pool: both counters should be changed by 1
id := ctr.Run(ctx, t, c, ctr.WithNetworkMode(netName))
defer c.ContainerRemove(ctx, id, client.ContainerRemoveOptions{Force: true})
checkSubnets(netName, map[netip.Prefix]networktypes.SubnetStatus{
cidrv4: {
IPsInUse: 6,
DynamicIPsAvailable: 0,
},
cidrv6: {
IPsInUse: 5,
DynamicIPsAvailable: 252,
},
})
// Out of IPRange pools: subnet counter should be changed by 1
id = ctr.Run(ctx, t, c,
ctr.WithNetworkMode(netName),
ctr.WithIPv4(netName, prefIPv4OutOfRange),
ctr.WithIPv6(netName, prefIPv6OutOfRange),
)
defer c.ContainerRemove(ctx, id, client.ContainerRemoveOptions{Force: true})
checkSubnets(netName, map[netip.Prefix]networktypes.SubnetStatus{
cidrv4: {
IPsInUse: 7,
DynamicIPsAvailable: 0, // unchanged
},
cidrv6: {
IPsInUse: 6,
DynamicIPsAvailable: 252, // unchanged
},
})
}()
// Counters should decrease after container removal
checkSubnets(netName, map[netip.Prefix]networktypes.SubnetStatus{
cidrv4: {
IPsInUse: 5,
DynamicIPsAvailable: 1,
},
cidrv6: {
IPsInUse: 4,
DynamicIPsAvailable: 253,
},
})
oldc := d.NewClientT(t, client.WithVersion("1.51"))
nw, err := oldc.NetworkInspect(ctx, netName, client.NetworkInspectOptions{})
if assert.Check(t, err) {
assert.Check(t, nw.Status == nil, "expected nil Status with API version 1.51")
}
})
t.Run("IPv6", func(t *testing.T) {
const netName = "testipambridgev6"
cidr := netip.MustParsePrefix("2001:db8:abcd::/56")
network.CreateNoError(ctx, t, c, netName,
network.WithIPv4(false),
network.WithIPv6(),
network.WithIPAMConfig(networktypes.IPAMConfig{
Subnet: cidr.String(),
}),
)
defer c.NetworkRemove(ctx, netName)
checkSubnets(netName, map[netip.Prefix]networktypes.SubnetStatus{
cidr: {
IPsInUse: 2,
DynamicIPsAvailable: math.MaxUint64,
},
})
})
}

View File

@@ -5,10 +5,12 @@ package ipvlan
import (
"context"
"fmt"
"net/netip"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/moby/moby/api/types/network"
dclient "github.com/moby/moby/client"
"github.com/moby/moby/v2/daemon/libnetwork/netlabel"
@@ -484,6 +486,11 @@ func TestIpvlanIPAM(t *testing.T) {
},
}
var (
subnetv4 = netip.MustParsePrefix("10.42.42.0/24")
subnetv6 = netip.MustParsePrefix("2001:db8:abcd::/64")
)
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := testutil.StartSpan(ctx, t)
@@ -492,6 +499,15 @@ func TestIpvlanIPAM(t *testing.T) {
netOpts := []func(*dclient.NetworkCreateOptions){
net.WithIPvlan("", "l3"),
net.WithIPv4(tc.enableIPv4),
net.WithIPAMConfig(
network.IPAMConfig{
Subnet: subnetv4.String(),
},
network.IPAMConfig{
Subnet: subnetv6.String(),
IPRange: "2001:db8:abcd::100/120",
},
),
}
if tc.enableIPv6 {
netOpts = append(netOpts, net.WithIPv6())
@@ -509,10 +525,15 @@ func TestIpvlanIPAM(t *testing.T) {
assert.Check(t, is.Contains(loRes.Combined(), " inet "))
assert.Check(t, is.Contains(loRes.Combined(), " inet6 "))
wantSubnetStatus := make(map[netip.Prefix]network.SubnetStatus)
eth0Res := container.ExecT(ctx, t, c, id, []string{"ip", "a", "show", "dev", "eth0"})
if tc.enableIPv4 || tc.expIPv4 {
assert.Check(t, is.Contains(eth0Res.Combined(), " inet "),
"Expected IPv4 in: %s", eth0Res.Combined())
wantSubnetStatus[subnetv4] = network.SubnetStatus{
IPsInUse: 3, // network, broadcast, container
DynamicIPsAvailable: 253,
}
} else {
assert.Check(t, !strings.Contains(eth0Res.Combined(), " inet "),
"Expected no IPv4 in: %s", eth0Res.Combined())
@@ -520,6 +541,10 @@ func TestIpvlanIPAM(t *testing.T) {
if tc.enableIPv6 {
assert.Check(t, is.Contains(eth0Res.Combined(), " inet6 "),
"Expected IPv6 in: %s", eth0Res.Combined())
wantSubnetStatus[subnetv6] = network.SubnetStatus{
IPsInUse: 2, // subnet-router anycast, container
DynamicIPsAvailable: 255,
}
} else {
assert.Check(t, !strings.Contains(eth0Res.Combined(), " inet6 "),
"Expected no IPv6 in: %s", eth0Res.Combined())
@@ -531,10 +556,188 @@ func TestIpvlanIPAM(t *testing.T) {
expDisableIPv6 = "0"
}
assert.Check(t, is.Equal(strings.TrimSpace(sysctlRes.Combined()), expDisableIPv6))
cc := d.NewClientT(t, dclient.WithVersion("1.52"))
inspect, err := cc.NetworkInspect(ctx, netName, dclient.NetworkInspectOptions{})
if assert.Check(t, err) && assert.Check(t, inspect.Status != nil) {
assert.Check(t, is.DeepEqual(wantSubnetStatus, inspect.Status.IPAM.Subnets, cmpopts.EquateEmpty()))
}
cc.Close()
cc = d.NewClientT(t, dclient.WithVersion("1.51"))
inspect, err = cc.NetworkInspect(ctx, netName, dclient.NetworkInspectOptions{})
assert.Check(t, err)
assert.Check(t, inspect.Status == nil)
cc.Close()
})
}
}
// IPVLAN networks are allowed to be assigned IPAM subnets that overlap with
// other IPVLAN networks' IPAM subnets. But no two IPVLAN endpoints may be
// assigned the same address, even when the endpoints are attached to different
// networks. The assignment of an address to an endpoint on one network may
// therefore reduce the number of available addresses to assign to other
// networks' endpoints.
func TestIpvlanIPAMOverlap(t *testing.T) {
skip.If(t, testEnv.IsRemoteDaemon)
skip.If(t, testEnv.IsRootless, "rootless mode has different view of network")
ctx := testutil.StartSpan(baseContext, t)
d := daemon.New(t)
d.StartWithBusybox(ctx, t)
defer d.Stop(t)
c := d.NewClientT(t)
checkNetworkIPAMState := func(networkID string, want map[netip.Prefix]network.SubnetStatus) bool {
t.Helper()
nw, err := c.NetworkInspect(ctx, networkID, dclient.NetworkInspectOptions{})
if assert.Check(t, err) && assert.Check(t, nw.Status != nil) {
return assert.Check(t, is.DeepEqual(want, nw.Status.IPAM.Subnets, cmpopts.EquateEmpty()))
}
return false
}
// Create three networks with joined and overlapped IPAM ranges
// and verify that the IPAM state is correct
const (
netName1 = "ipvlannet1"
netName2 = "ipvlannet2"
netName3 = "ipvlannet3"
)
cidrv4 := netip.MustParsePrefix("192.168.0.0/24")
cidrv6 := netip.MustParsePrefix("2001:db8:abcd::/64")
net.CreateNoError(ctx, t, c, netName1,
net.WithIPvlan("", "l3"),
net.WithIPv6(),
net.WithIPAMConfig(
network.IPAMConfig{
Subnet: cidrv4.String(),
IPRange: "192.168.0.0/25",
Gateway: "192.168.0.1",
AuxAddress: map[string]string{
"reserved": "192.168.0.100",
},
},
network.IPAMConfig{
Subnet: cidrv6.String(),
IPRange: "2001:db8:abcd::/124",
},
),
)
defer c.NetworkRemove(ctx, netName1)
assert.Check(t, n.IsNetworkAvailable(ctx, c, netName1))
checkNetworkIPAMState(netName1, map[netip.Prefix]network.SubnetStatus{
cidrv4: {
IPsInUse: 4,
DynamicIPsAvailable: 125,
},
cidrv6: {
IPsInUse: 1,
DynamicIPsAvailable: 15,
},
})
net.CreateNoError(ctx, t, c, netName2,
net.WithIPvlan("", "l3"),
net.WithIPv6(),
net.WithIPAMConfig(
network.IPAMConfig{
Subnet: cidrv4.String(),
IPRange: "192.168.0.0/24",
},
network.IPAMConfig{
Subnet: cidrv6.String(),
IPRange: "2001:db8:abcd::/120",
},
),
)
defer c.NetworkRemove(ctx, netName2)
assert.Check(t, n.IsNetworkAvailable(ctx, c, netName2))
checkNetworkIPAMState(netName2, map[netip.Prefix]network.SubnetStatus{
cidrv4: {
IPsInUse: 4,
DynamicIPsAvailable: 252,
},
cidrv6: {
IPsInUse: 1,
DynamicIPsAvailable: 255,
},
})
net.CreateNoError(ctx, t, c, netName3,
net.WithIPvlan("", "l3"),
net.WithIPv6(),
net.WithIPAMConfig(
network.IPAMConfig{
Subnet: cidrv4.String(),
IPRange: "192.168.0.128/25",
},
network.IPAMConfig{
Subnet: cidrv6.String(),
IPRange: "2001:db8:abcd::80/124",
},
),
)
defer c.NetworkRemove(ctx, netName3)
assert.Check(t, n.IsNetworkAvailable(ctx, c, netName3))
checkNetworkIPAMState(netName3, map[netip.Prefix]network.SubnetStatus{
cidrv4: {
IPsInUse: 4,
DynamicIPsAvailable: 127,
},
cidrv6: {
IPsInUse: 1,
DynamicIPsAvailable: 16,
},
})
// Create a container on one of the networks
id := container.Run(ctx, t, c, container.WithNetworkMode(netName1))
defer c.ContainerRemove(ctx, id, dclient.ContainerRemoveOptions{Force: true})
// Verify that the IPAM status of all three networks are affected.
checkNetworkIPAMState(netName1, map[netip.Prefix]network.SubnetStatus{
cidrv4: {
IPsInUse: 5,
DynamicIPsAvailable: 124,
},
cidrv6: {
IPsInUse: 2,
DynamicIPsAvailable: 14,
},
})
checkNetworkIPAMState(netName2, map[netip.Prefix]network.SubnetStatus{
cidrv4: {
IPsInUse: 5,
DynamicIPsAvailable: 251,
},
cidrv6: {
IPsInUse: 2,
DynamicIPsAvailable: 254,
},
})
checkNetworkIPAMState(netName3, map[netip.Prefix]network.SubnetStatus{
cidrv4: {
IPsInUse: 5,
DynamicIPsAvailable: 127,
},
cidrv6: {
IPsInUse: 2,
DynamicIPsAvailable: 16,
},
})
}
// TestIPVlanDNS checks whether DNS is forwarded, for combinations of l2/l3 mode,
// with/without a parent interface, and with '--internal'. Note that, there's no
// attempt here to give the ipvlan network external connectivity - when this test

View File

@@ -4,10 +4,12 @@ package macvlan
import (
"context"
"net/netip"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/client"
"github.com/moby/moby/v2/daemon/libnetwork/netlabel"
@@ -480,6 +482,10 @@ func TestMacvlanIPAM(t *testing.T) {
expIPv4: true,
},
}
var (
subnetv4 = netip.MustParsePrefix("10.66.77.0/24")
subnetv6 = netip.MustParsePrefix("2001:db8:abcd::/64")
)
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
@@ -490,6 +496,21 @@ func TestMacvlanIPAM(t *testing.T) {
net.WithMacvlan(""),
net.WithOption("macvlan_mode", "bridge"),
net.WithIPv4(tc.enableIPv4),
net.WithIPAMConfig(
network.IPAMConfig{
Subnet: subnetv4.String(),
IPRange: "10.66.77.64/30",
Gateway: "10.66.77.1",
AuxAddress: map[string]string{
"inrange": "10.66.77.65",
"outofrange": "10.66.77.128",
},
},
network.IPAMConfig{
Subnet: subnetv6.String(),
IPRange: "2001:db8:abcd::/120",
},
),
}
if tc.enableIPv6 {
netOpts = append(netOpts, net.WithIPv6())
@@ -507,10 +528,15 @@ func TestMacvlanIPAM(t *testing.T) {
assert.Check(t, is.Contains(loRes.Combined(), " inet "))
assert.Check(t, is.Contains(loRes.Combined(), " inet6 "))
wantSubnetStatus := make(map[netip.Prefix]network.SubnetStatus)
eth0Res := container.ExecT(ctx, t, c, id, []string{"ip", "a", "show", "dev", "eth0"})
if tc.enableIPv4 || tc.expIPv4 {
assert.Check(t, is.Contains(eth0Res.Combined(), " inet "),
"Expected IPv4 in: %s", eth0Res.Combined())
wantSubnetStatus[subnetv4] = network.SubnetStatus{
IPsInUse: 6, // network, gateway, 2x aux, broadcast, container
DynamicIPsAvailable: 2, // container, aux "inrange"
}
} else {
assert.Check(t, !strings.Contains(eth0Res.Combined(), " inet "),
"Expected no IPv4 in: %s", eth0Res.Combined())
@@ -518,6 +544,10 @@ func TestMacvlanIPAM(t *testing.T) {
if tc.enableIPv6 {
assert.Check(t, is.Contains(eth0Res.Combined(), " inet6 "),
"Expected IPv6 in: %s", eth0Res.Combined())
wantSubnetStatus[subnetv6] = network.SubnetStatus{
IPsInUse: 2, // subnet-router anycast, container
DynamicIPsAvailable: 254,
}
} else {
assert.Check(t, !strings.Contains(eth0Res.Combined(), " inet6 "),
"Expected no IPv6 in: %s", eth0Res.Combined())
@@ -529,10 +559,188 @@ func TestMacvlanIPAM(t *testing.T) {
expDisableIPv6 = "0"
}
assert.Check(t, is.Equal(strings.TrimSpace(sysctlRes.Combined()), expDisableIPv6))
cc := d.NewClientT(t, client.WithVersion("1.52"))
inspect, err := cc.NetworkInspect(ctx, netName, client.NetworkInspectOptions{})
if assert.Check(t, err) && assert.Check(t, inspect.Status != nil) {
assert.Check(t, is.DeepEqual(wantSubnetStatus, inspect.Status.IPAM.Subnets, cmpopts.EquateEmpty()))
}
cc.Close()
cc = d.NewClientT(t, client.WithVersion("1.51"))
inspect, err = cc.NetworkInspect(ctx, netName, client.NetworkInspectOptions{})
assert.Check(t, err)
assert.Check(t, inspect.Status == nil)
cc.Close()
})
}
}
// MACVLAN networks are allowed to be assigned IPAM subnets that overlap with
// other MACVLAN networks' IPAM subnets. But no two MACVLAN endpoints may be
// assigned the same address, even when the endpoints are attached to different
// networks. The assignment of an address to an endpoint on one network may
// therefore reduce the number of available addresses to assign to other
// networks' endpoints.
func TestMacvlanIPAMOverlap(t *testing.T) {
skip.If(t, testEnv.IsRemoteDaemon)
skip.If(t, testEnv.IsRootless, "rootless mode has different view of network")
ctx := testutil.StartSpan(baseContext, t)
d := daemon.New(t)
d.StartWithBusybox(ctx, t)
defer d.Stop(t)
c := d.NewClientT(t)
checkNetworkIPAMState := func(networkID string, want map[netip.Prefix]network.SubnetStatus) bool {
t.Helper()
nw, err := c.NetworkInspect(ctx, networkID, client.NetworkInspectOptions{})
if assert.Check(t, err) && assert.Check(t, nw.Status != nil) {
return assert.Check(t, is.DeepEqual(want, nw.Status.IPAM.Subnets, cmpopts.EquateEmpty()))
}
return false
}
// Create three networks with joined and overlapped IPAM ranges
// and verify that the IPAM state is correct
const (
netName1 = "macvlannet1"
netName2 = "macvlannet2"
netName3 = "macvlannet3"
)
cidrv4 := netip.MustParsePrefix("192.168.0.0/24")
cidrv6 := netip.MustParsePrefix("2001:db8:abcd::/64")
net.CreateNoError(ctx, t, c, netName1,
net.WithMacvlan(""),
net.WithIPv6(),
net.WithIPAMConfig(
network.IPAMConfig{
Subnet: cidrv4.String(),
IPRange: "192.168.0.0/25",
Gateway: "192.168.0.1",
AuxAddress: map[string]string{
"reserved": "192.168.0.100",
},
},
network.IPAMConfig{
Subnet: cidrv6.String(),
IPRange: "2001:db8:abcd::/124",
},
),
)
defer c.NetworkRemove(ctx, netName1)
assert.Check(t, n.IsNetworkAvailable(ctx, c, netName1))
checkNetworkIPAMState(netName1, map[netip.Prefix]network.SubnetStatus{
cidrv4: {
IPsInUse: 4,
DynamicIPsAvailable: 125,
},
cidrv6: {
IPsInUse: 1,
DynamicIPsAvailable: 15,
},
})
net.CreateNoError(ctx, t, c, netName2,
net.WithMacvlan(""),
net.WithIPv6(),
net.WithIPAMConfig(
network.IPAMConfig{
Subnet: cidrv4.String(),
IPRange: "192.168.0.0/24",
},
network.IPAMConfig{
Subnet: cidrv6.String(),
IPRange: "2001:db8:abcd::/120",
},
),
)
defer c.NetworkRemove(ctx, netName2)
assert.Check(t, n.IsNetworkAvailable(ctx, c, netName2))
checkNetworkIPAMState(netName2, map[netip.Prefix]network.SubnetStatus{
cidrv4: {
IPsInUse: 4,
DynamicIPsAvailable: 252,
},
cidrv6: {
IPsInUse: 1,
DynamicIPsAvailable: 255,
},
})
net.CreateNoError(ctx, t, c, netName3,
net.WithMacvlan(""),
net.WithIPv6(),
net.WithIPAMConfig(
network.IPAMConfig{
Subnet: cidrv4.String(),
IPRange: "192.168.0.128/25",
},
network.IPAMConfig{
Subnet: cidrv6.String(),
IPRange: "2001:db8:abcd::80/124",
},
),
)
defer c.NetworkRemove(ctx, netName3)
assert.Check(t, n.IsNetworkAvailable(ctx, c, netName3))
checkNetworkIPAMState(netName3, map[netip.Prefix]network.SubnetStatus{
cidrv4: {
IPsInUse: 4,
DynamicIPsAvailable: 127,
},
cidrv6: {
IPsInUse: 1,
DynamicIPsAvailable: 16,
},
})
// Create a container on one of the networks
id := container.Run(ctx, t, c, container.WithNetworkMode(netName1))
defer c.ContainerRemove(ctx, id, client.ContainerRemoveOptions{Force: true})
// Verify that the IPAM status of all three networks are affected.
checkNetworkIPAMState(netName1, map[netip.Prefix]network.SubnetStatus{
cidrv4: {
IPsInUse: 5,
DynamicIPsAvailable: 124,
},
cidrv6: {
IPsInUse: 2,
DynamicIPsAvailable: 14,
},
})
checkNetworkIPAMState(netName2, map[netip.Prefix]network.SubnetStatus{
cidrv4: {
IPsInUse: 5,
DynamicIPsAvailable: 251,
},
cidrv6: {
IPsInUse: 2,
DynamicIPsAvailable: 254,
},
})
checkNetworkIPAMState(netName3, map[netip.Prefix]network.SubnetStatus{
cidrv4: {
IPsInUse: 5,
DynamicIPsAvailable: 127,
},
cidrv6: {
IPsInUse: 2,
DynamicIPsAvailable: 16,
},
})
}
// TestMACVlanDNS checks whether DNS is forwarded, with/without a parent
// interface, and with '--internal'. Note that there's no attempt here to give
// the macvlan network external connectivity - when this test supplies a parent

View File

@@ -20,4 +20,8 @@ type Inspect struct {
// swarm scope networks, and omitted for local scope networks.
//
Services map[string]ServiceInfo `json:"Services,omitempty"`
// provides runtime information about the network such as the number of allocated IPs.
//
Status *Status `json:"Status,omitempty"`
}

View File

@@ -22,6 +22,8 @@ type IPAMConfig struct {
AuxAddress map[string]string `json:"AuxiliaryAddresses,omitempty"`
}
type SubnetStatuses = map[netip.Prefix]SubnetStatus
type ipFamily string
const (

View File

@@ -0,0 +1,16 @@
// Code generated by go-swagger; DO NOT EDIT.
package network
// This file was generated by the swagger tool.
// Editing this file might prove futile when you re-run the swagger generate command
// IPAMStatus IPAM status
//
// swagger:model IPAMStatus
type IPAMStatus struct {
// subnets
// Example: {"172.16.0.0/16":{"DynamicIPsAvailable":65533,"IPsInUse":3},"2001:db8:abcd:0012::0/96":{"DynamicIPsAvailable":4294967291,"IPsInUse":5}}
Subnets SubnetStatuses `json:"Subnets,omitempty"`
}

View File

@@ -0,0 +1,15 @@
// Code generated by go-swagger; DO NOT EDIT.
package network
// This file was generated by the swagger tool.
// Editing this file might prove futile when you re-run the swagger generate command
// Status provides runtime information about the network such as the number of allocated IPs.
//
// swagger:model Status
type Status struct {
// IPAM
IPAM IPAMStatus `json:"IPAM"`
}

View File

@@ -0,0 +1,20 @@
// Code generated by go-swagger; DO NOT EDIT.
package network
// This file was generated by the swagger tool.
// Editing this file might prove futile when you re-run the swagger generate command
// SubnetStatus subnet status
//
// swagger:model SubnetStatus
type SubnetStatus struct {
// Number of IP addresses in the subnet that are in use or reserved and are therefore unavailable for allocation, saturating at 2<sup>64</sup> - 1.
//
IPsInUse uint64 `json:"IPsInUse"`
// Number of IP addresses within the network's IPRange for the subnet that are available for allocation, saturating at 2<sup>64</sup> - 1.
//
DynamicIPsAvailable uint64 `json:"DynamicIPsAvailable"`
}