mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
refactor: move api, client, compose packages to pkg
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/go-units"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
LabelManaged = "uncloud.managed"
|
||||
LabelServiceID = "uncloud.service.id"
|
||||
LabelServiceName = "uncloud.service.name"
|
||||
LabelServiceMode = "uncloud.service.mode"
|
||||
LabelServicePorts = "uncloud.service.ports"
|
||||
LabelServiceSpecHash = "uncloud.service.spec-hash"
|
||||
)
|
||||
|
||||
type Container struct {
|
||||
types.ContainerJSON
|
||||
}
|
||||
|
||||
// NameWithoutSlash returns the container name without the leading slash.
|
||||
// TODO: modify Name in original ContainerJSON structure when inspecting a Docker container and get rid of this method.
|
||||
func (c *Container) NameWithoutSlash() string {
|
||||
return c.Name[1:]
|
||||
}
|
||||
|
||||
// ServiceID returns the ID of the service this container belongs to.
|
||||
func (c *Container) ServiceID() string {
|
||||
return c.Config.Labels[LabelServiceID]
|
||||
}
|
||||
|
||||
// ServiceName returns the name of the service this container belongs to.
|
||||
func (c *Container) ServiceName() string {
|
||||
return c.Config.Labels[LabelServiceName]
|
||||
}
|
||||
|
||||
// ServiceMode returns the replication mode of the service this container belongs to.
|
||||
func (c *Container) ServiceMode() string {
|
||||
return c.Config.Labels[LabelServiceMode]
|
||||
}
|
||||
|
||||
// ServicePorts returns the ports this container publishes as part of its service.
|
||||
func (c *Container) ServicePorts() ([]PortSpec, error) {
|
||||
encoded, ok := c.Config.Labels[LabelServicePorts]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.TrimSpace(encoded) == "" {
|
||||
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
|
||||
}
|
||||
|
||||
// ServiceSpec constructs a service spec from the container's configuration.
|
||||
func (c *Container) ServiceSpec() (ServiceSpec, error) {
|
||||
ports, err := c.ServicePorts()
|
||||
if err != nil {
|
||||
return ServiceSpec{}, fmt.Errorf("get service ports: %w", err)
|
||||
}
|
||||
|
||||
// TODO: many properties on the container such as Config.Cmd or Config.Entrypoint are populated when the container
|
||||
// is created. Figure out how to get a spec that is equal to the initial spec.
|
||||
return ServiceSpec{
|
||||
Container: ContainerSpec{
|
||||
Command: c.Config.Cmd,
|
||||
Entrypoint: c.Config.Entrypoint,
|
||||
Image: c.Config.Image,
|
||||
Init: c.HostConfig.Init,
|
||||
Volumes: c.HostConfig.Binds,
|
||||
},
|
||||
Mode: c.ServiceMode(),
|
||||
Name: c.ServiceName(),
|
||||
Ports: ports,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Healthy determines if the container is running and healthy.
|
||||
// A running container with no health check configured is considered healthy.
|
||||
func (c *Container) Healthy() bool {
|
||||
if !c.State.Running || c.State.Paused || c.State.Restarting {
|
||||
return false
|
||||
}
|
||||
|
||||
// If there's no health status (no health check configured), container is considered healthy.
|
||||
if c.State.Health == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return c.State.Health.Status == types.Healthy
|
||||
}
|
||||
|
||||
// HumanState returns a human-readable description of the container's state. Based on the Docker implementation:
|
||||
// https://github.com/moby/moby/blob/b343d235a0a1f30c8f05b1d651238e72158dc25d/container/state.go#L79-L113
|
||||
func (c *Container) HumanState() (string, error) {
|
||||
startedAt, err := time.Parse(time.RFC3339Nano, c.State.StartedAt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse started time: %w", err)
|
||||
}
|
||||
finishedAt, err := time.Parse(time.RFC3339Nano, c.State.FinishedAt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse finished time: %w", err)
|
||||
}
|
||||
|
||||
if c.State.Running {
|
||||
if c.State.Paused {
|
||||
return fmt.Sprintf("Up %s (Paused)", units.HumanDuration(time.Now().UTC().Sub(startedAt))), nil
|
||||
}
|
||||
if c.State.Restarting {
|
||||
return fmt.Sprintf("Restarting (%d) %s ago",
|
||||
c.State.ExitCode, units.HumanDuration(time.Now().UTC().Sub(finishedAt))), nil
|
||||
}
|
||||
|
||||
if c.State.Health != nil {
|
||||
status := c.State.Health.Status
|
||||
if status == types.Starting {
|
||||
status = "health: " + status
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Up %s (%s)", units.HumanDuration(time.Now().UTC().Sub(startedAt)), status), nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Up %s", units.HumanDuration(time.Now().UTC().Sub(startedAt))), nil
|
||||
}
|
||||
|
||||
if c.State.Status == "removing" {
|
||||
return "Removal In Progress", nil
|
||||
}
|
||||
|
||||
if c.State.Dead {
|
||||
return "Dead", nil
|
||||
}
|
||||
|
||||
if startedAt.IsZero() {
|
||||
return "Created", nil
|
||||
}
|
||||
|
||||
if finishedAt.IsZero() {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Exited (%d) %s ago",
|
||||
c.State.ExitCode, units.HumanDuration(time.Now().UTC().Sub(finishedAt))), nil
|
||||
}
|
||||
|
||||
// ConflictingServicePorts returns a list of service ports that conflict with the given ports.
|
||||
func (c *Container) ConflictingServicePorts(ports []PortSpec) ([]PortSpec, error) {
|
||||
svcPorts, err := c.ServicePorts()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get service ports: %w", err)
|
||||
}
|
||||
|
||||
var conflicting []PortSpec
|
||||
for _, p := range ports {
|
||||
if p.Mode != PortModeHost {
|
||||
continue
|
||||
}
|
||||
|
||||
// Two host ports conflict if they have the same published port number and protocol, and either:
|
||||
// * At least one host IP is not set (meaning it uses all interfaces)
|
||||
// * Both host IPs are identical
|
||||
for _, svcPort := range svcPorts {
|
||||
if svcPort.Mode != PortModeHost ||
|
||||
svcPort.PublishedPort != p.PublishedPort ||
|
||||
svcPort.Protocol != p.Protocol {
|
||||
continue
|
||||
}
|
||||
|
||||
if !svcPort.HostIP.IsValid() || !p.HostIP.IsValid() || svcPort.HostIP.Compare(p.HostIP) == 0 {
|
||||
conflicting = append(conflicting, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicting, nil
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContainer_ServiceSpec(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
init := true
|
||||
ctr := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
HostConfig: &container.HostConfig{
|
||||
Binds: []string{"/host/path:/container/path"},
|
||||
Init: &init,
|
||||
},
|
||||
},
|
||||
Config: &container.Config{
|
||||
Cmd: []string{"/app/server"},
|
||||
Image: "app:latest",
|
||||
Labels: map[string]string{
|
||||
LabelServiceID: "test-service-id",
|
||||
LabelServiceName: "test-service-name",
|
||||
LabelServicePorts: "app.example.com:8000/https",
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
expectedSpec := ServiceSpec{
|
||||
Container: ContainerSpec{
|
||||
Command: []string{"/app/server"},
|
||||
Image: "app:latest",
|
||||
Init: &init,
|
||||
Volumes: []string{"/host/path:/container/path"},
|
||||
},
|
||||
Name: "test-service-name",
|
||||
Ports: []PortSpec{
|
||||
{
|
||||
Hostname: "app.example.com",
|
||||
ContainerPort: 8000,
|
||||
Protocol: ProtocolHTTPS,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
spec, err := ctr.ServiceSpec()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, reflect.DeepEqual(spec, expectedSpec))
|
||||
}
|
||||
|
||||
func TestContainer_Healthy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("exited", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
Running: false,
|
||||
Dead: false,
|
||||
ExitCode: 0,
|
||||
},
|
||||
},
|
||||
}}
|
||||
assert.False(t, c.Healthy())
|
||||
})
|
||||
|
||||
t.Run("running with no health check", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
Running: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
assert.True(t, c.Healthy())
|
||||
})
|
||||
|
||||
t.Run("running and healthy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
Running: true,
|
||||
Health: &types.Health{
|
||||
Status: types.Healthy,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
assert.True(t, c.Healthy())
|
||||
})
|
||||
|
||||
t.Run("running but unhealthy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
Running: true,
|
||||
Health: &types.Health{
|
||||
Status: types.Unhealthy,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
assert.False(t, c.Healthy())
|
||||
})
|
||||
|
||||
t.Run("running with health starting", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
Running: true,
|
||||
Health: &types.Health{
|
||||
Status: "starting",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
assert.False(t, c.Healthy())
|
||||
})
|
||||
|
||||
t.Run("dead", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
Dead: true,
|
||||
Running: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
assert.False(t, c.Healthy())
|
||||
})
|
||||
|
||||
t.Run("restarting", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
Restarting: true,
|
||||
Running: true,
|
||||
ExitCode: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
assert.False(t, c.Healthy())
|
||||
})
|
||||
|
||||
t.Run("paused", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
||||
ContainerJSONBase: &types.ContainerJSONBase{
|
||||
State: &types.ContainerState{
|
||||
Paused: true,
|
||||
Running: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
assert.False(t, c.Healthy())
|
||||
})
|
||||
}
|
||||
|
||||
func TestContainer_ConflictingServicePorts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
containerPorts string
|
||||
checkPorts []PortSpec
|
||||
want []PortSpec
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "no conflicts when container has no ports",
|
||||
containerPorts: "",
|
||||
checkPorts: []PortSpec{
|
||||
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolTCP},
|
||||
},
|
||||
want: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "host mode ports with same published port and protocol conflict",
|
||||
containerPorts: "8080:80/tcp@host",
|
||||
checkPorts: []PortSpec{
|
||||
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolTCP},
|
||||
},
|
||||
want: []PortSpec{
|
||||
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolTCP},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "host mode ports with same port but different protocols don't conflict",
|
||||
containerPorts: "8080:80/tcp@host",
|
||||
checkPorts: []PortSpec{
|
||||
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolUDP},
|
||||
},
|
||||
want: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple protocols on same port don't conflict",
|
||||
containerPorts: "8080:80/tcp@host,8080:80/udp@host",
|
||||
checkPorts: []PortSpec{
|
||||
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolUDP},
|
||||
},
|
||||
want: []PortSpec{
|
||||
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolUDP},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "host mode ports with different published ports don't conflict",
|
||||
containerPorts: "8080:80/tcp@host",
|
||||
checkPorts: []PortSpec{
|
||||
{Mode: PortModeHost, PublishedPort: 8081, ContainerPort: 80, Protocol: ProtocolTCP},
|
||||
},
|
||||
want: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "host mode ports with same published port but different host IPs don't conflict",
|
||||
containerPorts: "127.0.0.1:8080:80/tcp@host",
|
||||
checkPorts: []PortSpec{
|
||||
{
|
||||
Mode: PortModeHost,
|
||||
HostIP: netip.MustParseAddr("127.0.0.2"),
|
||||
PublishedPort: 8080,
|
||||
ContainerPort: 80,
|
||||
Protocol: ProtocolTCP,
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "host mode ports with same published port, protocol, and host IP conflict",
|
||||
containerPorts: "127.0.0.1:8080:80/tcp@host",
|
||||
checkPorts: []PortSpec{
|
||||
{
|
||||
Mode: PortModeHost,
|
||||
HostIP: netip.MustParseAddr("127.0.0.1"),
|
||||
PublishedPort: 8080,
|
||||
ContainerPort: 80,
|
||||
Protocol: ProtocolTCP,
|
||||
},
|
||||
},
|
||||
want: []PortSpec{
|
||||
{
|
||||
Mode: PortModeHost,
|
||||
HostIP: netip.MustParseAddr("127.0.0.1"),
|
||||
PublishedPort: 8080,
|
||||
ContainerPort: 80,
|
||||
Protocol: ProtocolTCP,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "host mode port with no host IP conflicts with specific host IP on same port and protocol",
|
||||
containerPorts: "8080:80/tcp@host",
|
||||
checkPorts: []PortSpec{
|
||||
{
|
||||
Mode: PortModeHost,
|
||||
HostIP: netip.MustParseAddr("127.0.0.1"),
|
||||
PublishedPort: 8080,
|
||||
ContainerPort: 80,
|
||||
Protocol: ProtocolTCP,
|
||||
},
|
||||
},
|
||||
want: []PortSpec{
|
||||
{
|
||||
Mode: PortModeHost,
|
||||
HostIP: netip.MustParseAddr("127.0.0.1"),
|
||||
PublishedPort: 8080,
|
||||
ContainerPort: 80,
|
||||
Protocol: ProtocolTCP,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "host mode port with no host IP doesn't conflict with different protocol",
|
||||
containerPorts: "8080:80/tcp@host",
|
||||
checkPorts: []PortSpec{
|
||||
{
|
||||
Mode: PortModeHost,
|
||||
HostIP: netip.MustParseAddr("127.0.0.1"),
|
||||
PublishedPort: 8080,
|
||||
ContainerPort: 80,
|
||||
Protocol: ProtocolUDP,
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ingress mode ports don't conflict with host mode ports",
|
||||
containerPorts: "8080:80/tcp",
|
||||
checkPorts: []PortSpec{
|
||||
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolTCP},
|
||||
},
|
||||
want: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "container with invalid port spec returns error",
|
||||
containerPorts: "invalid:port:spec",
|
||||
checkPorts: []PortSpec{
|
||||
{Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolTCP},
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctr := &Container{ContainerJSON: types.ContainerJSON{
|
||||
Config: &container.Config{
|
||||
Labels: map[string]string{
|
||||
LabelServicePorts: tt.containerPorts,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
got, err := ctr.ConflictingServicePorts(tt.checkPorts)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
PortModeIngress = "ingress"
|
||||
PortModeHost = "host"
|
||||
|
||||
ProtocolHTTP = "http"
|
||||
ProtocolHTTPS = "https"
|
||||
ProtocolTCP = "tcp"
|
||||
ProtocolUDP = "udp"
|
||||
)
|
||||
|
||||
type PortSpec struct {
|
||||
// Hostname specifies the DNS name that will route to this service. Only valid in ingress mode.
|
||||
Hostname string
|
||||
// HostIP is the host IP to bind the PublishedPort to. Only valid in host mode.
|
||||
HostIP netip.Addr
|
||||
// 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
|
||||
// ContainerPort is the port inside the container that the service listens on.
|
||||
ContainerPort uint16
|
||||
// Protocol specifies the network protocol.
|
||||
Protocol string
|
||||
// Mode specifies how the port is published.
|
||||
Mode string
|
||||
}
|
||||
|
||||
func (p *PortSpec) Validate() error {
|
||||
if p.ContainerPort == 0 {
|
||||
return fmt.Errorf("container port must be non-zero")
|
||||
}
|
||||
|
||||
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'",
|
||||
p.Protocol, ProtocolHTTP, ProtocolHTTPS, ProtocolTCP, ProtocolUDP)
|
||||
}
|
||||
|
||||
switch p.Mode {
|
||||
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)
|
||||
}
|
||||
if p.Hostname != "" {
|
||||
if p.Protocol != ProtocolHTTP && p.Protocol != ProtocolHTTPS {
|
||||
return fmt.Errorf("hostname is only valid with '%s' or '%s' protocols", ProtocolHTTP, ProtocolHTTPS)
|
||||
}
|
||||
if err := validateHostname(p.Hostname); err != nil {
|
||||
return fmt.Errorf("invalid hostname '%s': %w", p.Hostname, err)
|
||||
}
|
||||
}
|
||||
case PortModeHost:
|
||||
if p.PublishedPort == 0 {
|
||||
return fmt.Errorf("published port is required in %s mode", PortModeHost)
|
||||
}
|
||||
if p.Protocol != ProtocolTCP && p.Protocol != ProtocolUDP {
|
||||
return fmt.Errorf("unsupported protocol '%s' in %s mode, only '%s' and '%s' are supported",
|
||||
p.Protocol, PortModeHost, ProtocolTCP, ProtocolUDP)
|
||||
}
|
||||
if p.Hostname != "" {
|
||||
return fmt.Errorf("hostname cannot be specified in %s mode", PortModeHost)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid mode: '%s'", p.Mode)
|
||||
}
|
||||
|
||||
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.
|
||||
Mode: PortModeIngress, // Default mode.
|
||||
}
|
||||
|
||||
// Split off mode first.
|
||||
parts := strings.Split(port, "@")
|
||||
if len(parts) > 2 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
port = parts[0]
|
||||
|
||||
// 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 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)
|
||||
}
|
||||
|
||||
if parts[0] == "" {
|
||||
return spec, fmt.Errorf("hostname or published port must be specified, format: " +
|
||||
"hostname:container_port or published_port:container_port")
|
||||
}
|
||||
// Try to parse the first part as port.
|
||||
if publishedPort, err := parsePort(parts[0]); err == nil {
|
||||
spec.PublishedPort = publishedPort
|
||||
} else {
|
||||
// It's a hostname.
|
||||
if spec.Mode == PortModeHost {
|
||||
return spec, fmt.Errorf("hostname cannot be specified in host mode")
|
||||
}
|
||||
spec.Hostname = parts[0]
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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.
|
||||
ip := parts[0]
|
||||
// Strip brackets from IPv6 address if present.
|
||||
if strings.Contains(ip, ":") {
|
||||
if !strings.HasPrefix(ip, "[") {
|
||||
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]
|
||||
}
|
||||
|
||||
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.
|
||||
spec.Hostname = parts[0]
|
||||
}
|
||||
|
||||
default:
|
||||
return spec, fmt.Errorf("unexpected number of parts in port spec: %d", len(parts))
|
||||
}
|
||||
|
||||
if spec.Hostname != "" {
|
||||
if specifiedProtocol == "" {
|
||||
spec.Protocol = ProtocolHTTPS
|
||||
} else if specifiedProtocol != ProtocolHTTP && specifiedProtocol != ProtocolHTTPS {
|
||||
return spec, fmt.Errorf("hostname is only valid with '%s' or '%s' protocols, specified: '%s'",
|
||||
ProtocolHTTP, ProtocolHTTPS, specifiedProtocol)
|
||||
}
|
||||
}
|
||||
|
||||
return spec, spec.Validate()
|
||||
}
|
||||
|
||||
// splitPortParts splits a port specification [hostname|host_ip:][published_port:]container_port into its parts.
|
||||
func splitPortParts(port string) []string {
|
||||
parts := strings.Split(port, ":")
|
||||
n := len(parts)
|
||||
if n > 3 {
|
||||
// Host IP may contain colons if it's IPv6, so we need to join the first n-2 parts.
|
||||
return append([]string{strings.Join(parts[:n-2], ":")}, parts[n-2:]...)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func parsePort(s string) (uint16, error) {
|
||||
port, err := strconv.ParseUint(s, 10, 16)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint16(port), nil
|
||||
}
|
||||
|
||||
func validateHostname(hostname string) error {
|
||||
if hostname == "" {
|
||||
return fmt.Errorf("must not be empty")
|
||||
}
|
||||
if !strings.Contains(hostname, ".") {
|
||||
return fmt.Errorf("must be a valid domain name containing at least one dot")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"net/netip"
|
||||
"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 without hostname http",
|
||||
spec: PortSpec{
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolHTTP,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ingress mode without hostname https",
|
||||
spec: PortSpec{
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolHTTPS,
|
||||
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: "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()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
port string
|
||||
expected PortSpec
|
||||
wantErr string
|
||||
}{
|
||||
// Ingress mode (default).
|
||||
{
|
||||
name: "container port only",
|
||||
port: "8080",
|
||||
expected: PortSpec{
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolTCP,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "published and container port",
|
||||
port: "8000:8080",
|
||||
expected: PortSpec{
|
||||
PublishedPort: 8000,
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolTCP,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "published port zero",
|
||||
port: "0:8080",
|
||||
expected: PortSpec{
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolTCP,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tcp protocol explicit",
|
||||
port: "8080/tcp",
|
||||
expected: PortSpec{
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolTCP,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "udp protocol",
|
||||
port: "8080/udp",
|
||||
expected: PortSpec{
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolUDP,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "published port udp",
|
||||
port: "8000:8080/udp",
|
||||
expected: PortSpec{
|
||||
PublishedPort: 8000,
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolUDP,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hostname and container port",
|
||||
port: "app.example.com:8080",
|
||||
expected: PortSpec{
|
||||
Hostname: "app.example.com",
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolHTTPS,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "container port http without hostname",
|
||||
port: "8080/http",
|
||||
expected: PortSpec{
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolHTTP,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hostname and container port http",
|
||||
port: "app.example.com:8080/http",
|
||||
expected: PortSpec{
|
||||
Hostname: "app.example.com",
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolHTTP,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hostname and published port",
|
||||
port: "app.example.com:6443:8080",
|
||||
expected: PortSpec{
|
||||
Hostname: "app.example.com",
|
||||
PublishedPort: 6443,
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolHTTPS,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hostname and published port http",
|
||||
port: "app.example.com:8000:8080/http",
|
||||
expected: PortSpec{
|
||||
Hostname: "app.example.com",
|
||||
PublishedPort: 8000,
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolHTTP,
|
||||
Mode: PortModeIngress,
|
||||
},
|
||||
},
|
||||
|
||||
// Host mode.
|
||||
{
|
||||
name: "host mode published with protocol",
|
||||
port: "80:8080/udp@host",
|
||||
expected: PortSpec{
|
||||
PublishedPort: 80,
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolUDP,
|
||||
Mode: PortModeHost,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "host mode with IPv4",
|
||||
port: "127.0.0.1:80:8080@host",
|
||||
expected: PortSpec{
|
||||
HostIP: netip.MustParseAddr("127.0.0.1"),
|
||||
PublishedPort: 80,
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolTCP,
|
||||
Mode: PortModeHost,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "host mode with IPv6",
|
||||
port: "[2001:db8::1234:5678]:80:8080@host",
|
||||
expected: PortSpec{
|
||||
HostIP: netip.MustParseAddr("2001:db8::1234:5678"),
|
||||
PublishedPort: 80,
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolTCP,
|
||||
Mode: PortModeHost,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "host mode with IP and protocol",
|
||||
port: "127.0.0.1:80:8080/udp@host",
|
||||
expected: PortSpec{
|
||||
HostIP: netip.MustParseAddr("127.0.0.1"),
|
||||
PublishedPort: 80,
|
||||
ContainerPort: 8080,
|
||||
Protocol: ProtocolUDP,
|
||||
Mode: PortModeHost,
|
||||
},
|
||||
},
|
||||
|
||||
// Error cases.
|
||||
{
|
||||
name: "empty",
|
||||
port: "",
|
||||
wantErr: "invalid container port",
|
||||
},
|
||||
{
|
||||
name: "invalid container port",
|
||||
port: "invalid",
|
||||
wantErr: "invalid container port",
|
||||
},
|
||||
{
|
||||
name: "container port zero",
|
||||
port: "0",
|
||||
wantErr: "container port must be non-zero",
|
||||
},
|
||||
{
|
||||
name: "out of range container port",
|
||||
port: "100500",
|
||||
wantErr: "invalid container port",
|
||||
},
|
||||
{
|
||||
name: "just protocol",
|
||||
port: "/tcp",
|
||||
wantErr: "invalid container port",
|
||||
},
|
||||
{
|
||||
name: "just mode",
|
||||
port: "@host",
|
||||
wantErr: "invalid container port",
|
||||
},
|
||||
{
|
||||
name: "multiple @ symbols",
|
||||
port: "8080@host@host",
|
||||
wantErr: "too many '@' symbols",
|
||||
},
|
||||
{
|
||||
name: "invalid mode",
|
||||
port: "8080@invalid",
|
||||
wantErr: "invalid mode: 'invalid'",
|
||||
},
|
||||
{
|
||||
name: "multiple protocols",
|
||||
port: "8080/tcp/udp",
|
||||
wantErr: "too many '/' symbols",
|
||||
},
|
||||
{
|
||||
name: "invalid protocol",
|
||||
port: "8080/invalid",
|
||||
wantErr: "unsupported protocol: 'invalid'",
|
||||
},
|
||||
{
|
||||
name: "invalid published port",
|
||||
port: "app.example.com:invalid:8080",
|
||||
wantErr: "invalid published port",
|
||||
},
|
||||
{
|
||||
name: "invalid hostname",
|
||||
port: "app:8080/http",
|
||||
wantErr: "invalid hostname 'app': must be a valid domain name containing at least one dot",
|
||||
},
|
||||
{
|
||||
name: "hostname with tcp protocol",
|
||||
port: "app.example.com:8080/tcp",
|
||||
wantErr: "hostname is only valid with 'http' or 'https' protocols",
|
||||
},
|
||||
|
||||
{
|
||||
name: "missing published port in host mode",
|
||||
port: "8080@host",
|
||||
wantErr: "published port is required in host mode",
|
||||
},
|
||||
{
|
||||
name: "missing published port with protocol in host mode",
|
||||
port: "8080/udp@host",
|
||||
wantErr: "published port is required in host mode",
|
||||
},
|
||||
{
|
||||
name: "invalid host IPv4",
|
||||
port: "300.0.0.1:80:8080@host",
|
||||
wantErr: "invalid host IP",
|
||||
},
|
||||
{
|
||||
name: "invalid host IPv6",
|
||||
port: "[:::1]:80:8080@host",
|
||||
wantErr: "invalid host IP",
|
||||
},
|
||||
{
|
||||
name: "missing closing bracket in IPv6",
|
||||
port: "[::1:80:8080@host",
|
||||
wantErr: "invalid host IP",
|
||||
},
|
||||
{
|
||||
name: "missing brackets in IPv6",
|
||||
port: "2001:db8::1234:5678:80:8080@host",
|
||||
wantErr: "invalid host IP",
|
||||
},
|
||||
{
|
||||
name: "http in host mode",
|
||||
port: "80:8080/http@host",
|
||||
wantErr: "unsupported protocol 'http' in host mode, only 'tcp' and 'udp' are supported",
|
||||
},
|
||||
{
|
||||
name: "https in host mode",
|
||||
port: "80:8080/https@host",
|
||||
wantErr: "unsupported protocol 'https' in host mode, only 'tcp' and 'udp' are supported",
|
||||
},
|
||||
{
|
||||
name: "hostname in host mode",
|
||||
port: "app.example.com:8080@host",
|
||||
wantErr: "hostname cannot be specified in host mode",
|
||||
},
|
||||
{
|
||||
name: "hostname with invalid published port",
|
||||
port: "app.example.com:invalid:8080@host",
|
||||
wantErr: "invalid published port",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := ParsePortSpec(tt.port)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err, "Expected error: %s, got nil, spec: %+v", tt.wantErr, spec)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, spec)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"maps"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
)
|
||||
|
||||
const (
|
||||
ServiceModeReplicated = "replicated"
|
||||
ServiceModeGlobal = "global"
|
||||
)
|
||||
|
||||
var serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$")
|
||||
|
||||
func ValidateServiceID(id string) bool {
|
||||
return serviceIDRegexp.MatchString(id)
|
||||
}
|
||||
|
||||
type ServiceSpec struct {
|
||||
Container ContainerSpec
|
||||
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
|
||||
Mode string
|
||||
Name string
|
||||
// Ports defines what service ports to publish to make the service accessible outside the cluster.
|
||||
Ports []PortSpec
|
||||
// Replicas is the number of containers to run for the service. Only valid for a replicated service.
|
||||
Replicas uint
|
||||
}
|
||||
|
||||
func (s *ServiceSpec) Validate() error {
|
||||
if err := s.Container.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch s.Mode {
|
||||
case "", ServiceModeGlobal, ServiceModeReplicated:
|
||||
default:
|
||||
return fmt.Errorf("invalid mode: %q", s.Mode)
|
||||
}
|
||||
|
||||
for _, p := range s.Ports {
|
||||
if (p.Mode == "" || p.Mode == PortModeIngress) &&
|
||||
p.Protocol != ProtocolHTTP && p.Protocol != ProtocolHTTPS {
|
||||
return fmt.Errorf("unsupported protocol for ingress port %d: %s", p.ContainerPort, p.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: validate there is no conflict between ports.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImmutableHash returns a hash of the immutable parts of the ServiceSpec that require container recreation if changed.
|
||||
func (s *ServiceSpec) ImmutableHash() (string, error) {
|
||||
var err error
|
||||
// Serialise and sort the ports to ensure the hash is consistent.
|
||||
ports := make([]string, len(s.Ports))
|
||||
for i, p := range s.Ports {
|
||||
ports[i], err = p.String()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode service port spec: %w", err)
|
||||
}
|
||||
}
|
||||
slices.Sort(ports)
|
||||
|
||||
volumes := make([]string, 0, len(s.Container.Volumes))
|
||||
volumes = append(volumes, s.Container.Volumes...)
|
||||
slices.Sort(volumes)
|
||||
|
||||
hashSpec := immutableHashSpec{
|
||||
Command: s.Container.Command,
|
||||
Entrypoint: s.Container.Entrypoint,
|
||||
Image: s.Container.Image,
|
||||
Init: s.Container.Init,
|
||||
Ports: ports,
|
||||
Volumes: volumes,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(hashSpec)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal immutable hash spec: %w", err)
|
||||
}
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err = hasher.Write(data); err != nil {
|
||||
return "", fmt.Errorf("write to SHA256 hasher: %w", err)
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// immutableHashSpec contains only the immutable fields from ServiceSpec that require container recreation if changed.
|
||||
type immutableHashSpec struct {
|
||||
Command []string `json:",omitempty"`
|
||||
Entrypoint []string `json:",omitempty"`
|
||||
Image string
|
||||
Init *bool `json:",omitempty"`
|
||||
// Ports are set as labels on the container which are immutable.
|
||||
// TODO: store ingress ports in the cluster store instead of as labels which will allow changing them without
|
||||
// recreating the container.
|
||||
Ports []string `json:",omitempty"`
|
||||
Volumes []string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// Equals returns true if the service spec is equal to the given spec ignoring the number of replicas.
|
||||
func (s *ServiceSpec) Equals(spec ServiceSpec) bool {
|
||||
// TODO: ignore order of ports.
|
||||
sCopy := *s
|
||||
// Ignore the number of replicas when comparing.
|
||||
sCopy.Replicas = 0
|
||||
spec.Replicas = 0
|
||||
return reflect.DeepEqual(*s, spec)
|
||||
}
|
||||
|
||||
type ContainerSpec struct {
|
||||
// Command overrides the default CMD of the image to be executed when running a container.
|
||||
Command []string
|
||||
// Entrypoint overrides the default ENTRYPOINT of the image.
|
||||
Entrypoint []string
|
||||
Image string
|
||||
// Run a custom init inside the container. If nil, use the daemon's configured settings.
|
||||
Init *bool
|
||||
// List of volumes to bind mount into the container.
|
||||
Volumes []string
|
||||
}
|
||||
|
||||
func (s *ContainerSpec) Validate() error {
|
||||
if _, err := reference.ParseDockerRef(s.Image); err != nil {
|
||||
return fmt.Errorf("invalid image: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
ID string
|
||||
Name string
|
||||
Mode string
|
||||
Containers []MachineContainer
|
||||
}
|
||||
|
||||
type MachineContainer struct {
|
||||
MachineID string
|
||||
Container Container
|
||||
}
|
||||
|
||||
// Endpoints returns the exposed HTTP and HTTPS endpoints of the service.
|
||||
func (s *Service) Endpoints() []string {
|
||||
endpoints := make(map[string]struct{})
|
||||
|
||||
// Container specs may differ between containers in the same service, e.g. during a rolling update,
|
||||
// so we need to collect all unique endpoints.
|
||||
for _, ctr := range s.Containers {
|
||||
ports, err := ctr.Container.ServicePorts()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, port := range ports {
|
||||
protocol := ""
|
||||
switch port.Protocol {
|
||||
case ProtocolHTTP:
|
||||
protocol = "http"
|
||||
case ProtocolHTTPS:
|
||||
protocol = "https"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if port.Hostname == "" {
|
||||
// There shouldn't be http(s) ports without a hostname but just in case ignore them.
|
||||
continue
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s://%s", protocol, port.Hostname)
|
||||
if port.PublishedPort != 0 {
|
||||
// For non-standard ports (80/443), include the port in the URL.
|
||||
if !(port.Protocol == ProtocolHTTP && port.PublishedPort == 80) &&
|
||||
!(port.Protocol == ProtocolHTTPS && port.PublishedPort == 443) {
|
||||
endpoint += fmt.Sprintf(":%d", port.PublishedPort)
|
||||
}
|
||||
}
|
||||
|
||||
endpoint += fmt.Sprintf(" → :%d", port.ContainerPort)
|
||||
endpoints[endpoint] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return slices.Sorted(maps.Keys(endpoints))
|
||||
}
|
||||
|
||||
func ServiceFromProto(s *pb.Service) (Service, error) {
|
||||
var err error
|
||||
containers := make([]MachineContainer, len(s.Containers))
|
||||
for i, sc := range s.Containers {
|
||||
containers[i], err = machineContainerFromProto(sc)
|
||||
if err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return Service{
|
||||
ID: s.Id,
|
||||
Name: s.Name,
|
||||
Mode: s.Mode,
|
||||
Containers: containers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func machineContainerFromProto(sc *pb.Service_Container) (MachineContainer, error) {
|
||||
var c Container
|
||||
if err := json.Unmarshal(sc.Container, &c); err != nil {
|
||||
return MachineContainer{}, fmt.Errorf("unmarshal container: %w", err)
|
||||
}
|
||||
|
||||
return MachineContainer{
|
||||
MachineID: sc.MachineId,
|
||||
Container: c,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user