refactor: move api, client, compose packages to pkg

This commit is contained in:
Pavel Sviderski
2025-03-22 18:43:00 +10:00
parent 3757813b20
commit b1abd07dde
42 changed files with 82 additions and 82 deletions
+189
View File
@@ -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
}
+346
View File
@@ -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
View File
@@ -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
}
+690
View File
@@ -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)
})
}
}
+228
View File
@@ -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
}