Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions cmd/nerdctl/network/network_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func createCommand() *cobra.Command {
cmd.Flags().StringArray("subnet", nil, `Subnet in CIDR format that represents a network segment, e.g. "10.5.0.0/16"`)
cmd.Flags().StringArray("gateway", nil, "IPv4 or IPv6 Gateway for the master subnet")
cmd.Flags().String("ip-range", "", `Allocate container ip from a sub-range`)
cmd.Flags().StringArray("aux-address", nil, "Auxiliary IPv4 or IPv6 addresses used by Network driver, as name=IP pairs. The IPs are reserved and never assigned to containers")
cmd.Flags().StringArray("label", nil, "Set metadata for a network")
cmd.Flags().Bool("ipv6", false, "Enable IPv6 networking")
cmd.Flags().Bool("internal", false, "Restrict external access to the network")
Expand Down Expand Up @@ -92,6 +93,10 @@ func createAction(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
auxAddresses, err := cmd.Flags().GetStringArray("aux-address")
if err != nil {
return err
}
labels, err := cmd.Flags().GetStringArray("label")
if err != nil {
return err
Expand All @@ -107,17 +112,18 @@ func createAction(cmd *cobra.Command, args []string) error {
}

return network.Create(types.NetworkCreateOptions{
GOptions: globalOptions,
Name: name,
Driver: driver,
Options: strutil.ConvertKVStringsToMap(opts),
IPAMDriver: ipamDriver,
IPAMOptions: strutil.ConvertKVStringsToMap(ipamOpts),
Subnets: subnets,
Gateway: gateways,
IPRange: ipRangeStr,
Labels: labels,
IPv6: ipv6,
Internal: internal,
GOptions: globalOptions,
Name: name,
Driver: driver,
Options: strutil.ConvertKVStringsToMap(opts),
IPAMDriver: ipamDriver,
IPAMOptions: strutil.ConvertKVStringsToMap(ipamOpts),
Subnets: subnets,
Gateway: gateways,
IPRange: ipRangeStr,
AuxAddresses: auxAddresses,
Labels: labels,
IPv6: ipv6,
Internal: internal,
}, cmd.OutOrStdout())
}
51 changes: 51 additions & 0 deletions cmd/nerdctl/network/network_create_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,57 @@ func TestNetworkCreate(t *testing.T) {
}
},
},
{
Description: "with aux-address",
Setup: func(data test.Data, helpers test.Helpers) {
helpers.Ensure("network", "create", data.Identifier(),
"--subnet", "10.6.0.0/24",
"--gateway", "10.6.0.1",
"--aux-address", "router=10.6.0.5",
"--aux-address", "dns=10.6.0.6",
)
},
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: expect.ExitCodeSuccess,
Output: func(stdout string, t tig.T) {
netw := nerdtest.InspectNetwork(helpers, data.Identifier())
var aux map[string]string
for _, c := range netw.IPAM.Config {
if c.Subnet == "10.6.0.0/24" {
aux = c.AuxiliaryAddresses
}
}
assert.Equal(t, aux["router"], "10.6.0.5")
assert.Equal(t, aux["dns"], "10.6.0.6")
},
}
},
},
{
Description: "aux-address is reserved",
Setup: func(data test.Data, helpers test.Helpers) {
helpers.Ensure("network", "create", data.Identifier(),
"--subnet", "10.6.1.0/24",
"--aux-address", "reserved=10.6.1.5",
)
},
Cleanup: func(data test.Data, helpers test.Helpers) {
helpers.Anyhow("network", "rm", data.Identifier())
},
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
// The reserved address is carved out of the range, so requesting
// it explicitly must fail just as it does on Docker.
return helpers.Command("run", "--rm", "--net", data.Identifier(), "--ip", "10.6.1.5", testutil.CommonImage, "true")
},
Expected: test.Expects(expect.ExitCodeGenericFail, nil, nil),
},
}

testCase.Run(t)
Expand Down
3 changes: 2 additions & 1 deletion docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1268,11 +1268,12 @@ Flags:
- :whale: `--subnet`: Subnet in CIDR format that represents a network segment, e.g. "10.5.0.0/16"
- :whale: `--gateway`: IPv4 or IPv6 Gateway for the master subnet
- :whale: `--ip-range`: Allocate container ip from a sub-range
- :whale: `--aux-address`: Auxiliary IPv4 or IPv6 addresses, as `name=IP` pairs. Each IP is reserved and never assigned to a container. Repeatable, and matched to the subnet that contains it.
- :whale: `--label`: Set metadata on a network
- :whale: `--ipv6`: Enable IPv6. Should be used with a valid subnet.
- :whale: `--internal`: Restrict external access to the network.

Unimplemented `docker network create` flags: `--attachable`, `--aux-address`, `--config-from`, `--config-only`, `--ingress`, `--scope`
Unimplemented `docker network create` flags: `--attachable`, `--config-from`, `--config-only`, `--ingress`, `--scope`

### :whale: nerdctl network ls

Expand Down
9 changes: 6 additions & 3 deletions pkg/api/types/network_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ type NetworkCreateOptions struct {
Subnets []string
Gateway []string
IPRange string
Labels []string
IPv6 bool
Internal bool
// AuxAddresses holds "name=IP" auxiliary addresses (docker --aux-address).
// Each IP is reserved so IPAM never hands it out to a container.
AuxAddresses []string
Labels []string
IPv6 bool
Internal bool
}

// NetworkInspectOptions specifies options for `nerdctl network inspect`.
Expand Down
10 changes: 10 additions & 0 deletions pkg/cmd/network/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ import (

func Create(options types.NetworkCreateOptions, stdout io.Writer) error {
if len(options.Subnets) == 0 {
// Docker matches each aux-address to a subnet that contains it, so
// without any subnet there is nothing to match. Surface the same
// "no matching subnet for aux-address <ip>" error Docker returns.
aux, err := netutil.ParseAuxAddresses(options.AuxAddresses)
if err != nil {
return err
}
for _, ip := range aux {
return fmt.Errorf("no matching subnet for aux-address %s", ip)
}
if len(options.Gateway) > 0 || options.IPRange != "" {
return fmt.Errorf("cannot set gateway or ip-range without subnet, specify --subnet manually")
}
Expand Down
15 changes: 11 additions & 4 deletions pkg/inspecttypes/dockercompat/dockercompat.go
Original file line number Diff line number Diff line change
Expand Up @@ -1044,9 +1044,10 @@ func getUlimitsFromNative(sp *specs.Spec) ([]*units.Ulimit, error) {
}

type IPAMConfig struct {
Subnet string `json:"Subnet,omitempty"`
Gateway string `json:"Gateway,omitempty"`
IPRange string `json:"IPRange,omitempty"`
Subnet string `json:"Subnet,omitempty"`
Gateway string `json:"Gateway,omitempty"`
IPRange string `json:"IPRange,omitempty"`
AuxiliaryAddresses map[string]string `json:"AuxiliaryAddresses,omitempty"`
}

type IPAM struct {
Expand Down Expand Up @@ -1188,7 +1189,13 @@ func NetworkFromNative(n *native.Network) (*Network, error) {
res.Name = sCNI.Name
for _, plugin := range sCNI.Plugins {
for _, ranges := range plugin.Ipam.Ranges {
res.IPAM.Config = append(res.IPAM.Config, ranges...)
// Each range-set describes one subnet. An aux-address reservation
// splits that subnet into several sub-ranges, but the first entry
// carries the subnet, gateway, ip-range and aux-addresses, so report
// just that one to keep one IPAM.Config entry per subnet like Docker.
if len(ranges) > 0 {
res.IPAM.Config = append(res.IPAM.Config, ranges[0])
}
}
}

Expand Down
5 changes: 5 additions & 0 deletions pkg/netutil/cni_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ type IPAMRange struct {
RangeEnd string `json:"rangeEnd,omitempty"`
Gateway string `json:"gateway,omitempty"`
IPRange string `json:"ipRange,omitempty"`
// AuxiliaryAddresses records the reserved name=IP pairs for this subnet.
// host-local does not read it (reservation is done by splitting the range
// around each IP); it is nerdctl bookkeeping so `network inspect` can
// report AuxiliaryAddresses the way Docker does.
AuxiliaryAddresses map[string]string `json:"auxiliaryAddresses,omitempty"`
}

type IPAMRoute struct {
Expand Down
122 changes: 121 additions & 1 deletion pkg/netutil/netutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package netutil

import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
Expand All @@ -28,6 +29,7 @@ import (
"path/filepath"
"sort"
"strconv"
"strings"

"github.com/containernetworking/cni/libcni"

Expand Down Expand Up @@ -339,7 +341,7 @@ func (e *CNIEnv) CreateNetwork(opts types.NetworkCreateOptions) (*NetworkConfig,
if _, ok := netMap[opts.Name]; ok {
return nil, errdefs.ErrAlreadyExists
}
ipam, err := e.generateIPAM(opts.IPAMDriver, opts.Subnets, opts.Gateway, opts.IPRange, opts.IPAMOptions, opts.IPv6, opts.Internal)
ipam, err := e.generateIPAM(opts.IPAMDriver, opts.Subnets, opts.Gateway, opts.IPRange, opts.AuxAddresses, opts.IPAMOptions, opts.IPv6, opts.Internal)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -619,6 +621,124 @@ func parseIPAMRange(subnet *net.IPNet, gatewayStr, ipRangeStr string) (*IPAMRang
return res, nil
}

// ParseAuxAddresses parses Docker-style "name=IP" auxiliary-address pairs into a
// name-to-IP map. Matching Docker, an entry with an empty IP (including one with
// no "=") is dropped, a later entry overrides an earlier one with the same name,
// and a non-empty but unparsable IP is an error.
func ParseAuxAddresses(raw []string) (map[string]string, error) {
if len(raw) == 0 {
return nil, nil
}
aux := make(map[string]string, len(raw))
for _, kv := range raw {
name, ip, _ := strings.Cut(kv, "=")
if ip == "" {
continue
}
if net.ParseIP(ip) == nil {
return nil, fmt.Errorf("invalid aux-address %q", ip)
}
aux[name] = ip
}
if len(aux) == 0 {
return nil, nil
}
return aux, nil
}

// splitIPAMRange reserves the given IPs inside a subnet's allocation range by
// 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.
func splitIPAMRange(subnet *net.IPNet, base *IPAMRange, reserved []net.IP) ([]IPAMRange, error) {
if len(reserved) == 0 {
return []IPAMRange{*base}, nil
}

// Resolve the allocation window. With an ip-range the base already carries
// its bounds; otherwise it is the whole subnet minus the all-ones address.
start := net.ParseIP(base.RangeStart)
if start == nil {
start, _ = subnetutil.FirstIPInSubnet(subnet)
}
end := net.ParseIP(base.RangeEnd)
if end == nil {
last, _ := subnetutil.LastIPInSubnet(subnet)
end = ipDec(last)
}

// Keep only the reserved IPs that fall within the window, sorted ascending.
inWindow := make([]net.IP, 0, len(reserved))
for _, ip := range reserved {
if bytes.Compare(ip.To16(), start.To16()) >= 0 && bytes.Compare(ip.To16(), end.To16()) <= 0 {
inWindow = append(inWindow, ip)
}
}
if len(inWindow) == 0 {
return []IPAMRange{*base}, nil
}
sort.Slice(inWindow, func(i, j int) bool {
return bytes.Compare(inWindow[i].To16(), inWindow[j].To16()) < 0
})

// Walk the window left to right, emitting a sub-range for each gap between
// reserved IPs.
var out []IPAMRange
cur := start
for _, r := range inWindow {
hi := ipDec(r)
if bytes.Compare(cur.To16(), hi.To16()) <= 0 {
out = append(out, IPAMRange{Subnet: subnet.String(), RangeStart: cur.String(), RangeEnd: hi.String()})
}
cur = ipInc(r)
}
if bytes.Compare(cur.To16(), end.To16()) <= 0 {
out = append(out, IPAMRange{Subnet: subnet.String(), RangeStart: cur.String(), RangeEnd: end.String()})
}
if len(out) == 0 {
return nil, fmt.Errorf("aux-address reservations leave no allocatable IPs in subnet %s", subnet)
}

// 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.
for i := range out {
out[i].Gateway = base.Gateway
}
out[0].IPRange = base.IPRange
return out, nil
}

// ipInc returns ip+1 and ipDec returns ip-1, both in 16-byte form and on a copy
// so the caller's IP is left untouched.
func ipInc(ip net.IP) net.IP {
out := make(net.IP, net.IPv6len)
copy(out, ip.To16())
for i := len(out) - 1; i >= 0; i-- {
out[i]++
if out[i] != 0 {
break
}
}
return out
}

func ipDec(ip net.IP) net.IP {
out := make(net.IP, net.IPv6len)
copy(out, ip.To16())
for i := len(out) - 1; i >= 0; i-- {
out[i]--
if out[i] != 0xff {
break
}
}
return out
}

// convert the struct to a map
func structToMap(in interface{}) (map[string]interface{}, error) {
out := make(map[string]interface{})
Expand Down
Loading
Loading