Skip to content
Merged
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
1 change: 0 additions & 1 deletion api/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ module github.com/moby/moby/api
go 1.23.0

require (
github.com/docker/go-connections v0.6.0
github.com/docker/go-units v0.5.0
github.com/google/go-cmp v0.7.0
github.com/moby/docker-image-spec v1.3.1
Expand Down
2 changes: 0 additions & 2 deletions api/go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
Expand Down
24 changes: 0 additions & 24 deletions api/types/container/nat_aliases.go

This file was deleted.

345 changes: 345 additions & 0 deletions api/types/container/network.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,345 @@
package container

import (
"errors"
"fmt"
"iter"
"strconv"
"strings"
"unique"
)

// NetworkProtocol represents a network protocol for a port.
type NetworkProtocol string

const (
TCP NetworkProtocol = "tcp"
UDP NetworkProtocol = "udp"
SCTP NetworkProtocol = "sctp"
)
Comment on lines +12 to +19
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly fine for a follow-up, but I noticed we define a type in types/swarm as well, so in som places we need to convert one to the other; wondering if we need to alias one to the other (or have an internal type that's used as alias for both);

// PortConfigProtocol represents the protocol of a port.
type PortConfigProtocol string
const (
// TODO(stevvooe): These should be used generally, not just for PortConfig.
// PortConfigProtocolTCP TCP
PortConfigProtocolTCP PortConfigProtocol = "tcp"
// PortConfigProtocolUDP UDP
PortConfigProtocolUDP PortConfigProtocol = "udp"
// PortConfigProtocolSCTP SCTP
PortConfigProtocolSCTP PortConfigProtocol = "sctp"
)


// Sentinel port proto value for zero Port and PortRange values.
var protoZero unique.Handle[NetworkProtocol]

// Port is a type representing a single port number and protocol in the format "<portnum>/[<proto>]".
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Micro-nit; the / is optional, but required when specifying a protocol; probably <portnum>[/<proto>] (or even <portnum>[</proto>] is more accurate. That last one is probably confusing though as it looks like a closing "html" tag 😂

//
// The zero port value, i.e. Port{}, is invalid; use [ParsePort] to create a valid Port value.
type Port struct {
num uint16
proto unique.Handle[NetworkProtocol]
}

// ParsePort parses s as a [Port].
//
// It normalizes the provided protocol such that "80/tcp", "80/TCP", and "80/tCp" are equivalent.
// If a port number is provided, but no protocol, the default ("tcp") protocol is returned.
func ParsePort(s string) (Port, error) {
if s == "" {
return Port{}, errors.New("invalid port: value is empty")
}

port, proto, _ := strings.Cut(s, "/")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silly question; do we want <port>/ (trailing slash, but no proto) to be considered valid, or (also see above) make it only accepted if used together with a proto?


portNum, err := parsePortNumber(port)
if err != nil {
return Port{}, fmt.Errorf("invalid port '%s': %w", port, err)
}

normalizedPortProto := normalizePortProto(proto)
return Port{num: portNum, proto: normalizedPortProto}, nil
}

// MustParsePort calls [ParsePort](s) and panics on error.
//
// It is intended for use in tests with hard-coded strings.
func MustParsePort(s string) Port {
p, err := ParsePort(s)
if err != nil {
panic(err)
}
return p
}

// PortFrom returns a [Port] with the given number and protocol.
//
// If no protocol is specified (i.e. proto == ""), then PortFrom returns Port{}, false.
func PortFrom(num uint16, proto NetworkProtocol) (p Port, ok bool) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only thing I'm eyeing is the use of uint16 in some places; while "correct" (as in it's he range accepted for ports), they also tend to be slightly awkward to use at times (e.g. a strconv.Itoa won't work).

But of course, it depends on how it's used; for this one, as it's constructing the Port, it means the caller already must've done' half the work (make sure the port-number is of the right type and range); I haven't looked yet how / where it's used and if that currently requires additional handling in callers 🤔

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. ParsePort is available for users who don't have a good reason to do half the work themselves.

if proto == "" {
return Port{}, false
}
normalized := normalizePortProto(string(proto))
return Port{num: num, proto: normalized}, true
}

// Num returns p's port number.
func (p Port) Num() uint16 {
Comment on lines +74 to +75
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slightly related to the above (also depend on how it's used).

(Also still hate that net.JoinHostPort requires a string for the port 😠 😂 )

return p.num
}

// Proto returns p's network protocol.
func (p Port) Proto() NetworkProtocol {
return p.proto.Value()
}

// IsZero reports whether p is the zero value.
func (p Port) IsZero() bool {
return p.proto == protoZero
}

// IsValid reports whether p is an initialized valid port (not the zero value).
func (p Port) IsValid() bool {
return p.proto != protoZero
}

// String returns a string representation of the port in the format "<portnum>/<proto>".
// If the port is the zero value, it returns "invalid port".
func (p Port) String() string {
switch p.proto {
case protoZero:
return "invalid port"
default:
return string(p.AppendTo(nil))
}
}

// AppendText implements [encoding.TextAppender] interface.
// It is the same as [Port.AppendTo] but returns an error to satisfy the interface.
func (p Port) AppendText(b []byte) ([]byte, error) {
return p.AppendTo(b), nil
}

// AppendTo appends a text encoding of p to b and returns the extended buffer.
func (p Port) AppendTo(b []byte) []byte {
if p.IsZero() {
return b
}
return fmt.Appendf(b, "%d/%s", p.num, p.proto.Value())
}

// MarshalText implements [encoding.TextMarshaler] interface.
func (p Port) MarshalText() ([]byte, error) {
return p.AppendText(nil)
}
Comment thread
corhere marked this conversation as resolved.

// UnmarshalText implements [encoding.TextUnmarshaler] interface.
func (p *Port) UnmarshalText(text []byte) error {
if len(text) == 0 {
*p = Port{}
return nil
}

port, err := ParsePort(string(text))
if err != nil {
return err
}

*p = port
return nil
}

// Range returns a [PortRange] representing the single port.
func (p Port) Range() PortRange {
return PortRange{start: p.num, end: p.num, proto: p.proto}
}

// PortSet is a collection of structs indexed by [Port].
type PortSet = map[Port]struct{}

// PortBinding represents a binding between a Host IP address and a Host Port.
type PortBinding struct {
// HostIP is the host IP Address
HostIP string `json:"HostIp"`
// HostPort is the host port number
HostPort string `json:"HostPort"`
}

// PortMap is a collection of [PortBinding] indexed by [Port].
type PortMap = map[Port][]PortBinding

// PortRange represents a range of port numbers and a protocol in the format "8000-9000/tcp".
//
// The zero port range value, i.e. PortRange{}, is invalid; use [ParsePortRange] to create a valid PortRange value.
type PortRange struct {
Comment thread
corhere marked this conversation as resolved.
start uint16
end uint16
proto unique.Handle[NetworkProtocol]
}

// ParsePortRange parses s as a [PortRange].
//
// It normalizes the provided protocol such that "80-90/tcp", "80-90/TCP", and "80-90/tCp" are equivalent.
// If a port number range is provided, but no protocol, the default ("tcp") protocol is returned.
func ParsePortRange(s string) (PortRange, error) {
if s == "" {
return PortRange{}, errors.New("invalid port range: value is empty")
}

portRange, proto, _ := strings.Cut(s, "/")

start, end, ok := strings.Cut(portRange, "-")
startVal, err := parsePortNumber(start)
if err != nil {
return PortRange{}, fmt.Errorf("invalid start port '%s': %w", start, err)
}

portProto := normalizePortProto(proto)

if !ok || start == end {
return PortRange{start: startVal, end: startVal, proto: portProto}, nil
}

endVal, err := parsePortNumber(end)
if err != nil {
return PortRange{}, fmt.Errorf("invalid end port '%s': %w", end, err)
}
if endVal < startVal {
return PortRange{}, errors.New("invalid port range: " + s)
}
return PortRange{start: startVal, end: endVal, proto: portProto}, nil
}

// MustParsePortRange calls [ParsePortRange](s) and panics on error.
// It is intended for use in tests with hard-coded strings.
func MustParsePortRange(s string) PortRange {
pr, err := ParsePortRange(s)
if err != nil {
panic(err)
}
return pr
}

// PortRangeFrom returns a [PortRange] with the given start and end port numbers and protocol.
//
// If end < start or no protocol is specified (i.e. proto == ""), then PortRangeFrom returns PortRange{}, false.
func PortRangeFrom(start, end uint16, proto NetworkProtocol) (pr PortRange, ok bool) {
if end < start || proto == "" {
return PortRange{}, false
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am now second-guessing how these cases are handled. I can't think of a good justification to explain why some edge case inputs result in a zero-value PortRange while others result in an error. It also violates the Go convention where err == nil (or ok == true) signals that the other result parameters are meaningful.

One possibility would be to implicitly normalize the port numbers: if end < start { start, end = end, start }. That would allow us to make PortRangeFrom consistent with PortFrom, returning the zero value iff the protocol is empty, and eliminating the need for an error result parameter. But that would be inconsistent with ParsePortRange, and I am having a hard time justifying why PortRangeFrom(2, 1, TCP) is acceptable but ParsePortRange("2-1/tcp") is not.

Do we simply drop the error result, returning PortRange{} when end < start || proto == ""? That doesn't seem right either, as that would marshal to the empty string.

Perhaps we're just overthinking things. What if we split the difference and return ok bool, like netip.AddrFromSlice?

// PortFrom returns a Port with the given number and protocol.
//
// If no protocol is specified (i.e. proto == ""), then PortFrom returns Port{}, false.
func PortFrom(num uint16, proto PortProto) (p port, ok bool)

// PortRangeFrom returns a [PortRange] with the given start and end port numbers and protocol.
//
// If end < start or no protocol is specified (i.e. proto == ""), then PortRangeFrom returns PortRange{}, false.
func PortRangeFrom(start, end uint16, proto PortProto) (r PortRange, ok bool)

It's not like the error string is particularly informative apart from its nilness. Returning multiple values forces the caller to acknowledge that the constructor is not guaranteed to return a valid value for all inputs, and to handle (or explicitly ignore) the unhappy path.

It is notable that netip.PrefixFrom has a single result parameter despite not being guaranteed to return a valid value for all inputs either. It returns an invalid but nonzero value when the bits parameter is out of range. I do not think that such behaviour would make sense for either of our PortFrom or PortRangeFrom constructors. A netip.Prefix wraps a netip.Addr; even if the prefix length is bogus the IP address still could be meaningful. There is value in encoding and passing around such invalid Prefixes. In contrast, when end < start in a PortRangeFrom call the only possibly well-formed piece of information is the protocol, which unlike the Addr of an invalid Prefix, is not meaningful out of context. Since the caller of PortFrom or PortRangeFrom is expected to get a well-formed PortRange as the result, the zero value is not a valid result for the function. Therefore returning a single result that may be zero-valued would be considered in-band signalling, which is not best practice.

In summary, my recommendation would be to have both PortFrom and PortRangeFrom return an ok bool result parameter.

Copy link
Copy Markdown
Contributor Author

@austinvazquez austinvazquez Sep 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay yeah I think I can get behind that. Even when authoring the error solution, it didn't feel as nice as the PortFrom implementation. +1 to the argument and reference to netip.AddrFromSlice. At least we can give a consistent experience across Port(Range)From functions while forcing the user to acknowledge the potential invalid value. Still brings most of the value of a compile time check.

normalized := normalizePortProto(string(proto))
return PortRange{start: start, end: end, proto: normalized}, true
}

// Start returns pr's start port number.
func (pr PortRange) Start() uint16 {
return pr.start
}

// End returns pr's end port number.
func (pr PortRange) End() uint16 {
return pr.end
}

// Proto returns pr's network protocol.
func (pr PortRange) Proto() NetworkProtocol {
return pr.proto.Value()
}

// IsZero reports whether pr is the zero value.
func (pr PortRange) IsZero() bool {
return pr.proto == protoZero
}

// IsValid reports whether pr is an initialized valid port range (not the zero value).
func (pr PortRange) IsValid() bool {
return pr.proto != protoZero
}

// String returns a string representation of the port range in the format "<start>-<end>/<proto>" or "<portnum>/<proto>" if start == end.
// If the port range is the zero value, it returns "invalid port range".
func (pr PortRange) String() string {
switch pr.proto {
case protoZero:
return "invalid port range"
default:
return string(pr.AppendTo(nil))
}
}

// AppendText implements [encoding.TextAppender] interface.
// It is the same as [PortRange.AppendTo] but returns an error to satisfy the interface.
func (pr PortRange) AppendText(b []byte) ([]byte, error) {
return pr.AppendTo(b), nil
}

// AppendTo appends a text encoding of pr to b and returns the extended buffer.
func (pr PortRange) AppendTo(b []byte) []byte {
if pr.IsZero() {
return b
}
if pr.start == pr.end {
return fmt.Appendf(b, "%d/%s", pr.start, pr.proto.Value())
}
return fmt.Appendf(b, "%d-%d/%s", pr.start, pr.end, pr.proto.Value())
Comment thread
austinvazquez marked this conversation as resolved.
}

// MarshalText implements [encoding.TextMarshaler] interface.
func (pr PortRange) MarshalText() ([]byte, error) {
return pr.AppendText(nil)
}

// UnmarshalText implements [encoding.TextUnmarshaler] interface.
func (pr *PortRange) UnmarshalText(text []byte) error {
if len(text) == 0 {
*pr = PortRange{}
return nil
}

portRange, err := ParsePortRange(string(text))
if err != nil {
return err
}
*pr = portRange
return nil
}

// Range returns pr.
func (pr PortRange) Range() PortRange {
return pr
}

// All returns an iterator over all the individual ports in the range.
//
// For example:
//
// for port := range pr.All() {
// // ...
// }
func (pr PortRange) All() iter.Seq[Port] {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could add a doc string

return func(yield func(Port) bool) {
for i := uint32(pr.Start()); i <= uint32(pr.End()); i++ {
if !yield(Port{num: uint16(i), proto: pr.proto}) {
return
}
}
}
}

// parsePortNumber parses rawPort into an int, unwrapping strconv errors
// and returning a single "out of range" error for any value outside 0–65535.
func parsePortNumber(rawPort string) (uint16, error) {
if rawPort == "" {
return 0, errors.New("value is empty")
}
port, err := strconv.ParseUint(rawPort, 10, 16)
if err != nil {
var numErr *strconv.NumError
if errors.As(err, &numErr) {
err = numErr.Err
}
return 0, err
}

return uint16(port), nil
}

// normalizePortProto normalizes the protocol string such that "tcp", "TCP", and "tCp" are equivalent.
// If proto is not specified, it defaults to "tcp".
func normalizePortProto(proto string) unique.Handle[NetworkProtocol] {
if proto == "" {
return unique.Make(TCP)
}

proto = strings.ToLower(proto)

return unique.Make(NetworkProtocol(proto))
}
Loading
Loading