add String() method for PortSpec to convert to publish format

This commit is contained in:
Pavel Sviderski
2024-12-10 19:14:26 +10:00
parent e8908ced9e
commit 4de7ecd747
3 changed files with 458 additions and 237 deletions
+29 -7
View File
@@ -3,34 +3,56 @@ package api
import (
"github.com/docker/docker/api/types"
"regexp"
"strings"
)
const (
LabelManaged = "uncloud.managed"
LabelServiceID = "uncloud.service.id"
LabelServiceName = "uncloud.service.name"
LabelServiceMode = "uncloud.service.mode"
LabelManaged = "uncloud.managed"
LabelServiceID = "uncloud.service.id"
LabelServiceName = "uncloud.service.name"
LabelServiceMode = "uncloud.service.mode"
LabelServicePorts = "uncloud.service.ports"
)
type Container struct {
types.Container
}
// ServiceID returns the service ID that the container is part of.
// ServiceID returns the ID of the service this container belongs to.
func (c *Container) ServiceID() string {
return c.Labels[LabelServiceID]
}
// ServiceName returns the service name that the container is part of.
// ServiceName returns the name of the service this container belongs to.
func (c *Container) ServiceName() string {
return c.Labels[LabelServiceName]
}
// ServiceMode returns the replication mode of the service that the container is part of.
// ServiceMode returns the replication mode of the service this container belongs to.
func (c *Container) ServiceMode() string {
return c.Labels[LabelServiceMode]
}
// ServicePorts returns the ports this container publishes as part of its service.
func (c *Container) ServicePorts() ([]PortSpec, error) {
encoded, ok := c.Labels[LabelServicePorts]
if !ok {
return nil, nil
}
publishPorts := strings.Split(encoded, ",")
ports := make([]PortSpec, len(publishPorts))
for i, p := range publishPorts {
port, err := ParsePortSpec(strings.TrimSpace(p))
if err != nil {
return nil, err
}
ports[i] = port
}
return ports, nil
}
// runningStatusRegex matches the status string of a running container.
// - "Up 3 minutes (healthy)" -> groups: ["Up 3 minutes (healthy)", "healthy"]
// - "Up 5 seconds" -> groups: ["Up 5 seconds", ""]
+44 -1
View File
@@ -39,6 +39,8 @@ func (p *PortSpec) Validate() error {
}
switch p.Protocol {
case "":
return fmt.Errorf("protocol must be specified")
case ProtocolHTTP, ProtocolHTTPS, ProtocolTCP, ProtocolUDP:
default:
return fmt.Errorf("invalid protocol '%s', supported protocols: '%s', '%s', '%s', '%s'",
@@ -46,7 +48,9 @@ func (p *PortSpec) Validate() error {
}
switch p.Mode {
case "", PortModeIngress: // Default mode is ingress if not specified.
case "":
return fmt.Errorf("mode must be specified")
case PortModeIngress:
if p.HostIP.IsValid() {
return fmt.Errorf("host IP cannot be specified in %s mode", PortModeIngress)
}
@@ -79,6 +83,45 @@ func (p *PortSpec) Validate() error {
return nil
}
// String returns the port specification in the -p/--publish flag format.
// Format:
// [hostname:][load_balancer_port:]container_port/protocol for ingress mode (default) or
// [host_ip:]:host_port:container_port/protocol@host for host mode.
func (p *PortSpec) String() (string, error) {
if err := p.Validate(); err != nil {
return "", err
}
var parts []string
switch p.Mode {
case "", PortModeIngress: // [hostname:][load_balancer_port:]container_port/protocol
if p.Hostname != "" {
parts = append(parts, p.Hostname)
}
if p.PublishedPort != 0 {
parts = append(parts, fmt.Sprint(p.PublishedPort))
}
parts = append(parts, fmt.Sprint(p.ContainerPort))
return fmt.Sprintf("%s/%s", strings.Join(parts, ":"), p.Protocol), nil
case PortModeHost: // [host_ip:]:host_port:container_port/protocol@host
if p.HostIP.IsValid() {
if p.HostIP.Is6() {
parts = append(parts, fmt.Sprintf("[%s]", p.HostIP))
} else {
parts = append(parts, p.HostIP.String())
}
}
parts = append(parts, fmt.Sprint(p.PublishedPort))
parts = append(parts, fmt.Sprint(p.ContainerPort))
return fmt.Sprintf("%s/%s@host", strings.Join(parts, ":"), p.Protocol), nil
default:
return "", fmt.Errorf("not implemented for mode: '%s'", p.Mode)
}
}
func ParsePortSpec(port string) (PortSpec, error) {
spec := PortSpec{
Protocol: ProtocolTCP, // Default protocol.
@@ -7,6 +7,391 @@ import (
"testing"
)
func TestPortSpec_Validate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spec PortSpec
wantErr string
}{
// Valid ingress mode.
{
name: "ingress mode tcp",
spec: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeIngress,
},
},
{
name: "ingress mode with published tcp port",
spec: PortSpec{
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeIngress,
},
},
{
name: "ingress mode udp",
spec: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolUDP,
Mode: PortModeIngress,
},
},
{
name: "ingress mode with published udp port",
spec: PortSpec{
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolUDP,
Mode: PortModeIngress,
},
},
{
name: "ingress mode with hostname and http",
spec: PortSpec{
Hostname: "app.example.com",
ContainerPort: 8080,
Protocol: ProtocolHTTP,
Mode: PortModeIngress,
},
},
{
name: "ingress mode with hostname and https",
spec: PortSpec{
Hostname: "app.example.com",
ContainerPort: 8080,
Protocol: ProtocolHTTPS,
Mode: PortModeIngress,
},
},
{
name: "ingress mode with hostname and published port",
spec: PortSpec{
Hostname: "app.example.com",
PublishedPort: 6443,
ContainerPort: 8080,
Protocol: ProtocolHTTPS,
Mode: PortModeIngress,
},
},
// Valid host mode.
{
name: "host mode tcp",
spec: PortSpec{
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolUDP,
Mode: PortModeHost,
},
},
{
name: "host mode udp",
spec: PortSpec{
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolUDP,
Mode: PortModeHost,
},
},
{
name: "host mode with IPv4",
spec: PortSpec{
HostIP: netip.MustParseAddr("127.0.0.1"),
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeHost,
},
},
{
name: "host mode with IPv6",
spec: PortSpec{
HostIP: netip.MustParseAddr("2001:db8::1234:5678"),
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolUDP,
Mode: PortModeHost,
},
},
// Error cases.
{
name: "missing container port",
spec: PortSpec{
Protocol: ProtocolTCP,
},
wantErr: "container port must be non-zero",
},
{
name: "invalid protocol",
spec: PortSpec{
ContainerPort: 8080,
Protocol: "invalid",
},
wantErr: "invalid protocol 'invalid'",
},
{
name: "invalid mode",
spec: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: "invalid",
},
wantErr: "invalid mode: 'invalid'",
},
{
name: "hostname with non-http protocol",
spec: PortSpec{
Hostname: "app.example.com",
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeIngress,
},
wantErr: "hostname is only valid with 'http' or 'https' protocols",
},
{
name: "invalid hostname",
spec: PortSpec{
Hostname: "app",
ContainerPort: 8080,
Protocol: ProtocolHTTPS,
Mode: PortModeIngress,
},
wantErr: "invalid hostname 'app': must be a valid domain name containing at least one dot",
},
{
name: "missing hostname with http",
spec: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolHTTP,
Mode: PortModeIngress,
},
wantErr: "hostname is required with 'http' or 'https' protocols",
},
{
name: "missing hostname with https",
spec: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolHTTPS,
Mode: PortModeIngress,
},
wantErr: "hostname is required with 'http' or 'https' protocols",
},
{
name: "host IP in ingress mode",
spec: PortSpec{
HostIP: netip.MustParseAddr("127.0.0.1"),
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeIngress,
},
wantErr: "host IP cannot be specified in ingress mode",
},
{
name: "zero published port in host mode",
spec: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeHost,
},
wantErr: "published port is required in host mode",
},
{
name: "hostname in host mode",
spec: PortSpec{
Hostname: "app.example.com",
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeHost,
},
wantErr: "hostname cannot be specified in host mode",
},
{
name: "http in host mode",
spec: PortSpec{
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolHTTP,
Mode: PortModeHost,
},
wantErr: "unsupported protocol 'http' in host mode",
},
{
name: "https in host mode",
spec: PortSpec{
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolHTTPS,
Mode: PortModeHost,
},
wantErr: "unsupported protocol 'https' in host mode",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.spec.Validate()
if tt.wantErr != "" {
require.Error(t, err, tt.wantErr)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
})
}
}
func TestPortSpec_String(t *testing.T) {
tests := []struct {
name string
spec PortSpec
expected string
}{
// Ingress mode.
{
name: "container port only",
spec: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeIngress,
},
expected: "8080/tcp",
},
{
name: "container port udp",
spec: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolUDP,
Mode: PortModeIngress,
},
expected: "8080/udp",
},
{
name: "published and container port",
spec: PortSpec{
ContainerPort: 8080,
PublishedPort: 80,
Protocol: ProtocolTCP,
Mode: PortModeIngress,
},
expected: "80:8080/tcp",
},
{
name: "hostname and container port https",
spec: PortSpec{
Hostname: "app.example.com",
ContainerPort: 8080,
Protocol: ProtocolHTTPS,
Mode: PortModeIngress,
},
expected: "app.example.com:8080/https",
},
{
name: "hostname and container port http",
spec: PortSpec{
Hostname: "app.example.com",
ContainerPort: 8080,
Protocol: ProtocolHTTP,
Mode: PortModeIngress,
},
expected: "app.example.com:8080/http",
},
{
name: "hostname and published and container port https",
spec: PortSpec{
Hostname: "app.example.com",
PublishedPort: 6443,
ContainerPort: 8080,
Protocol: ProtocolHTTPS,
Mode: PortModeIngress,
},
expected: "app.example.com:6443:8080/https",
},
{
name: "hostname and published and container port http",
spec: PortSpec{
Hostname: "app.example.com",
PublishedPort: 6443,
ContainerPort: 8080,
Protocol: ProtocolHTTP,
Mode: PortModeIngress,
},
expected: "app.example.com:6443:8080/http",
},
// Host mode.
{
name: "host mode tcp",
spec: PortSpec{
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeHost,
},
expected: "80:8080/tcp@host",
},
{
name: "host mode udp",
spec: PortSpec{
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolUDP,
Mode: PortModeHost,
},
expected: "80:8080/udp@host",
},
{
name: "host mode with IPv4 tcp",
spec: PortSpec{
HostIP: netip.MustParseAddr("127.0.0.1"),
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeHost,
},
expected: "127.0.0.1:80:8080/tcp@host",
},
{
name: "host mode with IPv4 udp",
spec: PortSpec{
HostIP: netip.MustParseAddr("127.0.0.1"),
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolUDP,
Mode: PortModeHost,
},
expected: "127.0.0.1:80:8080/udp@host",
},
{
name: "host mode with IPv6",
spec: PortSpec{
HostIP: netip.MustParseAddr("2001:db8::1234:5678"),
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeHost,
},
expected: "[2001:db8::1234:5678]:80:8080/tcp@host",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := tt.spec.String()
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestParsePortSpec(t *testing.T) {
t.Parallel()
@@ -311,232 +696,3 @@ func TestParsePortSpec(t *testing.T) {
})
}
}
func TestPortSpec_Validate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spec PortSpec
wantErr string
}{
// Valid ingress mode.
{
name: "ingress mode tcp",
spec: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeIngress,
},
},
{
name: "ingress mode tcp",
spec: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolTCP,
},
},
{
name: "ingress mode with published tcp port",
spec: PortSpec{
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolTCP,
},
},
{
name: "ingress mode udp",
spec: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolUDP,
},
},
{
name: "ingress mode with published udp port",
spec: PortSpec{
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolUDP,
},
},
{
name: "ingress mode with hostname and http",
spec: PortSpec{
Hostname: "app.example.com",
ContainerPort: 8080,
Protocol: ProtocolHTTP,
},
},
{
name: "ingress mode with hostname and https",
spec: PortSpec{
Hostname: "app.example.com",
ContainerPort: 8080,
Protocol: ProtocolHTTPS,
},
},
{
name: "ingress mode with hostname and published port",
spec: PortSpec{
Hostname: "app.example.com",
PublishedPort: 6443,
ContainerPort: 8080,
Protocol: ProtocolHTTPS,
},
},
// Valid host mode.
{
name: "host mode",
spec: PortSpec{
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeHost,
},
},
{
name: "host mode with IPv4",
spec: PortSpec{
HostIP: netip.MustParseAddr("127.0.0.1"),
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeHost,
},
},
{
name: "host mode with IPv6",
spec: PortSpec{
HostIP: netip.MustParseAddr("2001:db8::1234:5678"),
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolUDP,
Mode: PortModeHost,
},
},
// Error cases.
{
name: "missing container port",
spec: PortSpec{
Protocol: ProtocolTCP,
},
wantErr: "container port must be non-zero",
},
{
name: "invalid protocol",
spec: PortSpec{
ContainerPort: 8080,
Protocol: "invalid",
},
wantErr: "invalid protocol 'invalid'",
},
{
name: "invalid mode",
spec: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: "invalid",
},
wantErr: "invalid mode: 'invalid'",
},
{
name: "hostname with non-http protocol",
spec: PortSpec{
Hostname: "app.example.com",
ContainerPort: 8080,
Protocol: ProtocolTCP,
},
wantErr: "hostname is only valid with 'http' or 'https' protocols",
},
{
name: "invalid hostname",
spec: PortSpec{
Hostname: "app",
ContainerPort: 8080,
Protocol: ProtocolHTTPS,
},
wantErr: "invalid hostname 'app': must be a valid domain name containing at least one dot",
},
{
name: "missing hostname with http",
spec: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolHTTP,
},
wantErr: "hostname is required with 'http' or 'https' protocols",
},
{
name: "missing hostname with https",
spec: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolHTTPS,
},
wantErr: "hostname is required with 'http' or 'https' protocols",
},
{
name: "host IP in ingress mode",
spec: PortSpec{
HostIP: netip.MustParseAddr("127.0.0.1"),
ContainerPort: 8080,
Protocol: ProtocolTCP,
},
wantErr: "host IP cannot be specified in ingress mode",
},
{
name: "zero published port in host mode",
spec: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeHost,
},
wantErr: "published port is required in host mode",
},
{
name: "hostname in host mode",
spec: PortSpec{
Hostname: "app.example.com",
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeHost,
},
wantErr: "hostname cannot be specified in host mode",
},
{
name: "http in host mode",
spec: PortSpec{
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolHTTP,
Mode: PortModeHost,
},
wantErr: "unsupported protocol 'http' in host mode",
},
{
name: "https in host mode",
spec: PortSpec{
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolHTTPS,
Mode: PortModeHost,
},
wantErr: "unsupported protocol 'https' in host mode",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.spec.Validate()
if tt.wantErr != "" {
require.Error(t, err, tt.wantErr)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
})
}
}