mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
feat: allow to bind to multiple host IP addresses specified as CIDR prefix in x-ports (#358)
Signed-off-by: Miek Gieben <miek@miek.nl> Co-authored-by: Pasha Sviderski <me@psviderski.name>
This commit is contained in:
co-authored by
Pasha Sviderski
parent
3586a32987
commit
1247f961c2
+85
-50
@@ -23,6 +23,9 @@ type PortSpec struct {
|
||||
Hostname string
|
||||
// HostIP is the host IP to bind the PublishedPort to. Only valid in host mode.
|
||||
HostIP netip.Addr
|
||||
// HostPrefix is the host prefix to bind the PublishedPort to. Only valid in host mode. Either HostIP
|
||||
// is set or HostPrefix
|
||||
HostPrefix netip.Prefix
|
||||
// PublishedPort is the port number exposed outside the container.
|
||||
// In ingress mode, this is the load balancer port. In host mode, this is the port bound on the host.
|
||||
PublishedPort uint16
|
||||
@@ -55,6 +58,9 @@ func (p *PortSpec) Validate() error {
|
||||
if p.HostIP.IsValid() {
|
||||
return fmt.Errorf("host IP cannot be specified in %s mode", PortModeIngress)
|
||||
}
|
||||
if p.HostPrefix.IsValid() {
|
||||
return fmt.Errorf("host prefix cannot be specified in %s mode", PortModeIngress)
|
||||
}
|
||||
if p.Hostname != "" {
|
||||
if p.Protocol != ProtocolHTTP && p.Protocol != ProtocolHTTPS {
|
||||
return fmt.Errorf("hostname is only valid with '%s' or '%s' protocols", ProtocolHTTP, ProtocolHTTPS)
|
||||
@@ -64,6 +70,9 @@ func (p *PortSpec) Validate() error {
|
||||
}
|
||||
}
|
||||
case PortModeHost:
|
||||
if p.HostIP.IsValid() && p.HostPrefix.IsValid() {
|
||||
return fmt.Errorf("host IP and prefix cannot both be specified in %s mode", PortModeHost)
|
||||
}
|
||||
if p.PublishedPort == 0 {
|
||||
return fmt.Errorf("published port is required in %s mode", PortModeHost)
|
||||
}
|
||||
@@ -111,6 +120,16 @@ func (p *PortSpec) String() (string, error) {
|
||||
parts = append(parts, p.HostIP.String())
|
||||
}
|
||||
}
|
||||
if p.HostPrefix.IsValid() {
|
||||
if p.HostPrefix.Addr().Is6() {
|
||||
// Enclose the IPv6 address part in square brackets to disambiguate its colons from the
|
||||
// port separators, e.g. [2001:db8::]/64.
|
||||
parts = append(parts, fmt.Sprintf("[%s]/%d", p.HostPrefix.Addr(), p.HostPrefix.Bits()))
|
||||
} else {
|
||||
parts = append(parts, p.HostPrefix.String())
|
||||
}
|
||||
}
|
||||
|
||||
parts = append(parts, fmt.Sprint(p.PublishedPort))
|
||||
parts = append(parts, fmt.Sprint(p.ContainerPort))
|
||||
|
||||
@@ -122,56 +141,70 @@ func (p *PortSpec) String() (string, error) {
|
||||
|
||||
func ParsePortSpec(port string) (PortSpec, error) {
|
||||
spec := PortSpec{
|
||||
Protocol: ProtocolTCP, // Default protocol.
|
||||
Mode: PortModeIngress, // Default mode.
|
||||
Protocol: ProtocolTCP, // Default protocol.
|
||||
}
|
||||
|
||||
// Split off mode first.
|
||||
parts := strings.Split(port, "@")
|
||||
if len(parts) > 2 {
|
||||
if strings.Count(port, "@") > 1 {
|
||||
return spec, fmt.Errorf("too many '@' symbols")
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
if parts[1] != PortModeHost {
|
||||
return spec, fmt.Errorf("invalid mode: '%s', only 'host' mode is supported", parts[1])
|
||||
}
|
||||
spec.Mode = PortModeHost
|
||||
}
|
||||
port = parts[0]
|
||||
|
||||
// Parse protocol.
|
||||
parts = strings.Split(port, "/")
|
||||
if len(parts) > 2 {
|
||||
return spec, fmt.Errorf("too many '/' symbols")
|
||||
}
|
||||
specifiedProtocol := ""
|
||||
if len(parts) == 2 {
|
||||
protocol := parts[1]
|
||||
switch protocol {
|
||||
case ProtocolHTTP, ProtocolHTTPS, ProtocolTCP, ProtocolUDP:
|
||||
spec.Protocol = protocol
|
||||
specifiedProtocol = protocol
|
||||
default:
|
||||
return spec, fmt.Errorf("unsupported protocol: '%s'", protocol)
|
||||
parts := splitPortParts(port)
|
||||
specifiedProtocol := "" // Save the set protocol for PortModeIngress to set the correct default later.
|
||||
|
||||
mode := parts[len(parts)-1]
|
||||
if i := strings.Index(mode, "@"); i > -1 {
|
||||
spec.Mode = PortModeHost
|
||||
|
||||
if mode[i:] != "@"+PortModeHost {
|
||||
return spec, fmt.Errorf("invalid mode: '%s'", mode[i+1:])
|
||||
}
|
||||
mode = mode[:i] // drop @host, leave PORT/udp PORT/tcp or PORT
|
||||
if i := strings.Index(mode, "/"); i > -1 {
|
||||
switch mode[i+1:] {
|
||||
case ProtocolTCP:
|
||||
case ProtocolUDP:
|
||||
spec.Protocol = ProtocolUDP
|
||||
default:
|
||||
return spec, fmt.Errorf("unsupported protocol '%s' in host mode, only 'tcp' and 'udp' are supported",
|
||||
mode[i+1:])
|
||||
}
|
||||
|
||||
mode = mode[:i] // drop /udp or /tcp, leaving the port only
|
||||
}
|
||||
} else {
|
||||
spec.Mode = PortModeIngress
|
||||
|
||||
if i := strings.Index(mode, "/"); i > -1 {
|
||||
switch mode[i+1:] {
|
||||
case ProtocolTCP:
|
||||
spec.Protocol = ProtocolTCP
|
||||
case ProtocolUDP:
|
||||
spec.Protocol = ProtocolUDP
|
||||
case ProtocolHTTP:
|
||||
spec.Protocol = ProtocolHTTP
|
||||
case ProtocolHTTPS:
|
||||
spec.Protocol = ProtocolHTTPS
|
||||
default:
|
||||
return spec, fmt.Errorf("unsupported protocol: '%s'", mode[i+1:])
|
||||
}
|
||||
specifiedProtocol = mode[i+1:]
|
||||
|
||||
mode = mode[:i] // drop /xxx, leaving the port only
|
||||
}
|
||||
}
|
||||
port = parts[0]
|
||||
|
||||
var err error
|
||||
if spec.ContainerPort, err = parsePort(mode); err != nil {
|
||||
return spec, fmt.Errorf("invalid container port '%s': %w", mode, err)
|
||||
}
|
||||
|
||||
// Parse hostname/host IP and ports.
|
||||
parts = splitPortParts(port)
|
||||
var err error
|
||||
|
||||
switch len(parts) {
|
||||
case 1: // Just container port.
|
||||
if spec.ContainerPort, err = parsePort(parts[0]); err != nil {
|
||||
return spec, fmt.Errorf("invalid container port '%s': %w", parts[0], err)
|
||||
}
|
||||
case 1:
|
||||
// Container port already done.
|
||||
|
||||
case 2: // hostname:container_port or [load_balancer_port|host_port]:container_port
|
||||
if spec.ContainerPort, err = parsePort(parts[1]); err != nil {
|
||||
return spec, fmt.Errorf("invalid container port '%s': %w", parts[1], err)
|
||||
}
|
||||
|
||||
// Container port (parts[1]) already done
|
||||
if parts[0] == "" {
|
||||
return spec, fmt.Errorf("hostname or published port must be specified, format: " +
|
||||
"hostname:container_port or published_port:container_port")
|
||||
@@ -188,30 +221,32 @@ func ParsePortSpec(port string) (PortSpec, error) {
|
||||
}
|
||||
|
||||
case 3: // hostname:load_balancer_port:container_port or host_ip:host_port:container_port
|
||||
if spec.ContainerPort, err = parsePort(parts[2]); err != nil {
|
||||
return spec, fmt.Errorf("invalid container port '%s': %w", parts[2], err)
|
||||
}
|
||||
// Container port (parts[2]) already done
|
||||
if spec.PublishedPort, err = parsePort(parts[1]); err != nil {
|
||||
return spec, fmt.Errorf("invalid published port '%s': %w", parts[1], err)
|
||||
}
|
||||
|
||||
if spec.Mode == PortModeHost {
|
||||
// In host mode, the first part must be IP.
|
||||
// In host mode, the first part must be a host IP or prefix.
|
||||
ip := parts[0]
|
||||
// Strip brackets from IPv6 address if present.
|
||||
// Strip brackets from an IPv6 address if present.
|
||||
if strings.Contains(ip, ":") {
|
||||
if !strings.HasPrefix(ip, "[") {
|
||||
end := strings.Index(ip, "]")
|
||||
if !strings.HasPrefix(ip, "[") || end < 0 {
|
||||
return spec, fmt.Errorf(
|
||||
"invalid host IP '%s': IPv6 address must be enclosed in square brackets", ip)
|
||||
}
|
||||
if !strings.HasSuffix(ip, "]") {
|
||||
return spec, fmt.Errorf("invalid host IP '%s': missing closing bracket", ip)
|
||||
}
|
||||
ip = ip[1 : len(ip)-1]
|
||||
ip = ip[1:end] + ip[end+1:]
|
||||
}
|
||||
|
||||
if spec.HostIP, err = netip.ParseAddr(ip); err != nil {
|
||||
return spec, fmt.Errorf("invalid host IP '%s': %w", parts[0], err)
|
||||
if strings.Contains(ip, "/") {
|
||||
if spec.HostPrefix, err = netip.ParsePrefix(ip); err != nil {
|
||||
return spec, fmt.Errorf("invalid host prefix '%s': %w", parts[0], err)
|
||||
}
|
||||
} else {
|
||||
if spec.HostIP, err = netip.ParseAddr(ip); err != nil {
|
||||
return spec, fmt.Errorf("invalid host IP '%s': %w", parts[0], err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Hostname may be empty.
|
||||
|
||||
+118
-1
@@ -191,6 +191,16 @@ func TestPortSpec_Validate(t *testing.T) {
|
||||
},
|
||||
wantErr: "host IP cannot be specified in ingress mode",
|
||||
},
|
||||
{
|
||||
name: "host prefix in ingress mode",
|
||||
spec: PortSpec{
|
||||
HostPrefix: netip.MustParsePrefix("127.0.0.1/8"),
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolTCP,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
wantErr: "host prefix cannot be specified in ingress mode",
|
||||
},
|
||||
{
|
||||
name: "zero published port in host mode",
|
||||
spec: PortSpec{
|
||||
@@ -231,6 +241,18 @@ func TestPortSpec_Validate(t *testing.T) {
|
||||
},
|
||||
wantErr: "unsupported protocol 'https' in host mode",
|
||||
},
|
||||
{
|
||||
name: "both host ip and prefix",
|
||||
spec: PortSpec{
|
||||
HostIP: netip.MustParseAddr("127.0.0.1"),
|
||||
HostPrefix: netip.MustParsePrefix("127.0.0.0/8"),
|
||||
PublishedPort: 80,
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolTCP,
|
||||
Mode: PortModeHost,
|
||||
},
|
||||
wantErr: "host IP and prefix cannot both be specified in host mode",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -369,6 +391,17 @@ func TestPortSpec_String(t *testing.T) {
|
||||
},
|
||||
expected: "127.0.0.1:80:8080/udp@host",
|
||||
},
|
||||
{
|
||||
name: "host mode with IPv4 prefix",
|
||||
spec: PortSpec{
|
||||
HostPrefix: netip.MustParsePrefix("127.0.0.1/8"),
|
||||
PublishedPort: 80,
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolUDP,
|
||||
Mode: PortModeHost,
|
||||
},
|
||||
expected: "127.0.0.1/8:80:8080/udp@host",
|
||||
},
|
||||
{
|
||||
name: "host mode with IPv6",
|
||||
spec: PortSpec{
|
||||
@@ -380,6 +413,17 @@ func TestPortSpec_String(t *testing.T) {
|
||||
},
|
||||
expected: "[2001:db8::1234:5678]:80:8080/tcp@host",
|
||||
},
|
||||
{
|
||||
name: "host mode with IPv6 prefix",
|
||||
spec: PortSpec{
|
||||
HostPrefix: netip.MustParsePrefix("2001:db8::/64"),
|
||||
PublishedPort: 80,
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolUDP,
|
||||
Mode: PortModeHost,
|
||||
},
|
||||
expected: "[2001:db8::]/64:80:8080/udp@host",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -553,6 +597,39 @@ func TestParsePortSpec(t *testing.T) {
|
||||
Mode: PortModeHost,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "host mode with prefix and protocol",
|
||||
port: "192.168.76.0/24:80:8080/udp@host",
|
||||
expected: PortSpec{
|
||||
HostPrefix: netip.MustParsePrefix("192.168.76.0/24"),
|
||||
PublishedPort: 80,
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolUDP,
|
||||
Mode: PortModeHost,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "host mode with prefix without protocol",
|
||||
port: "192.168.76.0/24:80:8080@host",
|
||||
expected: PortSpec{
|
||||
HostPrefix: netip.MustParsePrefix("192.168.76.0/24"),
|
||||
PublishedPort: 80,
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolTCP,
|
||||
Mode: PortModeHost,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "host mode with IPv6 prefix and protocol",
|
||||
port: "[2001:db8::]/64:80:8080/udp@host",
|
||||
expected: PortSpec{
|
||||
HostPrefix: netip.MustParsePrefix("2001:db8::/64"),
|
||||
PublishedPort: 80,
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolUDP,
|
||||
Mode: PortModeHost,
|
||||
},
|
||||
},
|
||||
|
||||
// Error cases.
|
||||
{
|
||||
@@ -560,11 +637,31 @@ func TestParsePortSpec(t *testing.T) {
|
||||
port: "",
|
||||
wantErr: "invalid container port",
|
||||
},
|
||||
{
|
||||
name: "slash",
|
||||
port: "/",
|
||||
wantErr: "unsupported protocol",
|
||||
},
|
||||
{
|
||||
name: "at",
|
||||
port: "@",
|
||||
wantErr: "invalid mode",
|
||||
},
|
||||
{
|
||||
name: "invalid container port",
|
||||
port: "invalid",
|
||||
wantErr: "invalid container port",
|
||||
},
|
||||
{
|
||||
name: "no protocol",
|
||||
port: "53/",
|
||||
wantErr: "unsupported protocol",
|
||||
},
|
||||
{
|
||||
name: "no host modifier",
|
||||
port: "53@",
|
||||
wantErr: "invalid mode",
|
||||
},
|
||||
{
|
||||
name: "container port zero",
|
||||
port: "0",
|
||||
@@ -598,7 +695,7 @@ func TestParsePortSpec(t *testing.T) {
|
||||
{
|
||||
name: "multiple protocols",
|
||||
port: "8080/tcp/udp",
|
||||
wantErr: "too many '/' symbols",
|
||||
wantErr: "unsupported protocol: 'tcp/udp'",
|
||||
},
|
||||
{
|
||||
name: "invalid protocol",
|
||||
@@ -671,6 +768,26 @@ func TestParsePortSpec(t *testing.T) {
|
||||
port: "app.example.com:invalid:8080@host",
|
||||
wantErr: "invalid published port",
|
||||
},
|
||||
{
|
||||
name: "invalid prefix",
|
||||
port: "192.168.76.0/45:53:5353/udp@host",
|
||||
wantErr: "invalid host prefix",
|
||||
},
|
||||
{
|
||||
name: "valid prefix invalid protocol",
|
||||
port: "192.168.76.0/24:53:5353/http@host",
|
||||
wantErr: "unsupported protocol 'http' in host mode, only 'tcp' and 'udp' are supported",
|
||||
},
|
||||
{
|
||||
name: "valid prefix invalid protocol",
|
||||
port: "192.168.76.0/24:53:5353/invalid@host",
|
||||
wantErr: "unsupported protocol 'invalid' in host mode, only 'tcp' and 'udp' are supported",
|
||||
},
|
||||
{
|
||||
name: "valid prefix no protocol",
|
||||
port: "192.168.76.0/24:53:5353/@host",
|
||||
wantErr: "unsupported protocol '' in host mode, only 'tcp' and 'udp' are supported",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -112,10 +112,14 @@ func convertServicePortConfigToPortSpec(port types.ServicePortConfig) (api.PortS
|
||||
// Set host IP if specified
|
||||
if port.HostIP != "" {
|
||||
hostIP, err := netip.ParseAddr(port.HostIP)
|
||||
if err != nil {
|
||||
return spec, fmt.Errorf("invalid host IP %q: %w", port.HostIP, err)
|
||||
if err == nil {
|
||||
spec.HostIP = hostIP
|
||||
} else {
|
||||
spec.HostPrefix, err = netip.ParsePrefix(port.HostIP)
|
||||
if err != nil {
|
||||
return spec, fmt.Errorf("invalid host IP or prefix '%s': %w", port.HostIP, err)
|
||||
}
|
||||
}
|
||||
spec.HostIP = hostIP
|
||||
}
|
||||
|
||||
// Validate the resulting spec
|
||||
|
||||
@@ -58,6 +58,15 @@ func TestConvertStandardPortsToPortSpecs(t *testing.T) {
|
||||
{ContainerPort: 8080, PublishedPort: 80, Protocol: "tcp", Mode: "host", HostIP: mustParseAddr("::1")},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "host prefix",
|
||||
ports: []types.ServicePortConfig{
|
||||
{Target: 8080, Published: "80", Protocol: "tcp", HostIP: "192.168.76.0/24", Mode: "host"},
|
||||
},
|
||||
expected: []api.PortSpec{
|
||||
{ContainerPort: 8080, PublishedPort: 80, Protocol: "tcp", Mode: "host", HostPrefix: netip.MustParsePrefix("192.168.76.0/24")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -315,7 +315,7 @@ func TestServiceSpecFromCompose(t *testing.T) {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
|
||||
cmpOpts := cmp.Options{cmpopts.EquateEmpty(), cmpopts.EquateComparable(netip.Addr{})}
|
||||
cmpOpts := cmp.Options{cmpopts.EquateEmpty(), cmpopts.EquateComparable(netip.Addr{}, netip.Prefix{})}
|
||||
assert.True(t, cmp.Equal(spec, expectedSpec, cmpOpts...), cmp.Diff(spec, expectedSpec, cmpOpts...))
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user