diff --git a/internal/api/container.go b/internal/api/container.go index 230f6583..177306bc 100644 --- a/internal/api/container.go +++ b/internal/api/container.go @@ -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", ""] diff --git a/internal/api/port.go b/internal/api/port.go index 5d23892f..984439b8 100644 --- a/internal/api/port.go +++ b/internal/api/port.go @@ -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. diff --git a/internal/api/service_test.go b/internal/api/port_test.go similarity index 76% rename from internal/api/service_test.go rename to internal/api/port_test.go index d4816313..e6d062d6 100644 --- a/internal/api/service_test.go +++ b/internal/api/port_test.go @@ -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) - }) - } -}