mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
refactor: move api, client, compose packages to pkg
This commit is contained in:
@@ -1,189 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,690 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/distribution/reference"
|
||||
"maps"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
+4
-4
@@ -5,15 +5,15 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"net/netip"
|
||||
"os"
|
||||
"github.com/psviderski/uncloud/internal/cli/client"
|
||||
"github.com/psviderski/uncloud/internal/cli/client/connector"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/fs"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/sshexec"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/psviderski/uncloud/pkg/client/connector"
|
||||
"net/netip"
|
||||
"os"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/Masterminds/semver"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const (
|
||||
CaddyServiceName = "caddy"
|
||||
// CaddyImage is the official Caddy Docker image on Docker Hub: https://hub.docker.com/_/caddy
|
||||
CaddyImage = "caddy"
|
||||
)
|
||||
|
||||
var caddyImageTagRegex = regexp.MustCompile(`^2\.\d+\.\d+$`)
|
||||
|
||||
// NewCaddyDeployment creates a new deployment for a Caddy reverse proxy service.
|
||||
// The service is deployed in global mode to all machines in the cluster. If the image is not provided, the latest
|
||||
// version of the official Caddy Docker image is used.
|
||||
func (cli *Client) NewCaddyDeployment(image string, filter MachineFilter) (*Deployment, error) {
|
||||
latest, err := latestCaddyImage()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("look up latest Caddy image: %w", err)
|
||||
}
|
||||
|
||||
if image == "" {
|
||||
image = reference.FamiliarString(latest)
|
||||
}
|
||||
|
||||
spec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Command: []string{"caddy", "run", "-c", "/config/caddy.json", "--watch"},
|
||||
Image: image,
|
||||
Volumes: []string{"/var/lib/uncloud/caddy:/config"},
|
||||
},
|
||||
Mode: api.ServiceModeGlobal,
|
||||
Name: CaddyServiceName,
|
||||
Ports: []api.PortSpec{
|
||||
{
|
||||
PublishedPort: 80,
|
||||
ContainerPort: 80,
|
||||
Protocol: api.ProtocolTCP,
|
||||
Mode: api.PortModeHost,
|
||||
},
|
||||
{
|
||||
PublishedPort: 443,
|
||||
ContainerPort: 443,
|
||||
Protocol: api.ProtocolTCP,
|
||||
Mode: api.PortModeHost,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return cli.NewDeployment(spec, &RollingStrategy{MachineFilter: filter})
|
||||
}
|
||||
|
||||
// latestCaddyImage returns the latest image of the official Caddy Docker image on Docker Hub.
|
||||
// The latest image is determined by the latest version tag 2.x.x.
|
||||
func latestCaddyImage() (reference.NamedTagged, error) {
|
||||
repo, err := name.NewRepository(CaddyImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse image: %w", err)
|
||||
}
|
||||
tags, err := remote.List(repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list image tags: %w", err)
|
||||
}
|
||||
|
||||
// Default to the 'latest' tag but try to find the latest version tag 2.x.x.
|
||||
latestTag := "latest"
|
||||
var latestVersion *semver.Version
|
||||
for _, t := range tags {
|
||||
if !caddyImageTagRegex.MatchString(t) {
|
||||
continue
|
||||
}
|
||||
|
||||
v, err := semver.NewVersion(t)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if latestVersion == nil || v.GreaterThan(latestVersion) {
|
||||
latestVersion = v
|
||||
latestTag = t
|
||||
}
|
||||
}
|
||||
|
||||
image, err := reference.ParseDockerRef(CaddyImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse image: %w", err)
|
||||
}
|
||||
imageWithTag, err := reference.WithTag(image, latestTag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("set image tag: %w", err)
|
||||
}
|
||||
|
||||
return imageWithTag, nil
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
)
|
||||
|
||||
func TestClient_NewCaddyDeployment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cli := &Client{}
|
||||
|
||||
t.Run("latest image from Docker Hub", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deploy, err := cli.NewCaddyDeployment("", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "caddy", deploy.Spec.Name)
|
||||
assert.Equal(t, api.ServiceModeGlobal, deploy.Spec.Mode)
|
||||
assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, deploy.Spec.Container.Image)
|
||||
expectedPorts := []api.PortSpec{
|
||||
{
|
||||
PublishedPort: 80,
|
||||
ContainerPort: 80,
|
||||
Protocol: api.ProtocolTCP,
|
||||
Mode: api.PortModeHost,
|
||||
},
|
||||
{
|
||||
PublishedPort: 443,
|
||||
ContainerPort: 443,
|
||||
Protocol: api.ProtocolTCP,
|
||||
Mode: api.PortModeHost,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expectedPorts, deploy.Spec.Ports)
|
||||
// TODO:
|
||||
//assert.Equal(t, alwaysPullImage, deploy.Spec.Container.PullPolicy)
|
||||
})
|
||||
|
||||
t.Run("custom image", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
image := "my-caddy:1.2.3"
|
||||
deploy, err := cli.NewCaddyDeployment(image, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "caddy", deploy.Spec.Name)
|
||||
assert.Equal(t, api.ServiceModeGlobal, deploy.Spec.Mode)
|
||||
assert.Equal(t, image, deploy.Spec.Container.Image)
|
||||
expectedPorts := []api.PortSpec{
|
||||
{
|
||||
PublishedPort: 80,
|
||||
ContainerPort: 80,
|
||||
Protocol: api.ProtocolTCP,
|
||||
Mode: api.PortModeHost,
|
||||
},
|
||||
{
|
||||
PublishedPort: 443,
|
||||
ContainerPort: 443,
|
||||
Protocol: api.ProtocolTCP,
|
||||
Mode: api.PortModeHost,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expectedPorts, deploy.Spec.Ports)
|
||||
})
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"os"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// Client is a client for the machine API.
|
||||
type Client struct {
|
||||
connector Connector
|
||||
conn *grpc.ClientConn
|
||||
|
||||
pb.MachineClient
|
||||
pb.ClusterClient
|
||||
// Docker is a namespaced client for the Docker service to distinguish Uncloud-specific service container operations
|
||||
// from generic Docker operations.
|
||||
Docker *docker.Client
|
||||
}
|
||||
|
||||
// Connector is an interface for establishing a connection to the machine API.
|
||||
type Connector interface {
|
||||
Connect(ctx context.Context) (*grpc.ClientConn, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// New creates a new client for the machine API. The connector is used to establish the connection
|
||||
// either locally or remotely. The client is responsible for closing the connector.
|
||||
func New(ctx context.Context, connector Connector) (*Client, error) {
|
||||
c := &Client{
|
||||
connector: connector,
|
||||
}
|
||||
var err error
|
||||
c.conn, err = connector.Connect(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to machine: %w", err)
|
||||
}
|
||||
|
||||
c.MachineClient = pb.NewMachineClient(c.conn)
|
||||
c.ClusterClient = pb.NewClusterClient(c.conn)
|
||||
c.Docker = docker.NewClient(c.conn)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (cli *Client) Close() error {
|
||||
return errors.Join(cli.conn.Close(), cli.connector.Close())
|
||||
}
|
||||
|
||||
// progressOut returns an output stream for progress writer.
|
||||
func (cli *Client) progressOut() *streams.Out {
|
||||
return streams.NewOut(os.Stdout)
|
||||
}
|
||||
|
||||
// proxyToMachine returns a new context that proxies gRPC requests to the specified machine.
|
||||
func proxyToMachine(ctx context.Context, machine *pb.MachineInfo) context.Context {
|
||||
machineIP, _ := machine.Network.ManagementIp.ToAddr()
|
||||
md := metadata.Pairs("machines", machineIP.String())
|
||||
return metadata.NewOutgoingContext(ctx, md)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
func (cli *Client) InspectMachine(ctx context.Context, id string) (*pb.MachineMember, error) {
|
||||
machines, err := cli.ListMachines(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range machines {
|
||||
if m.Machine.Id == id || m.Machine.Name == id {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
func (cli *Client) ListMachines(ctx context.Context) ([]*pb.MachineMember, error) {
|
||||
resp, err := cli.ClusterClient.ListMachines(ctx, &emptypb.Empty{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Machines, nil
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/compose-spec/compose-go/v2/graph"
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
"github.com/psviderski/uncloud/internal/compose"
|
||||
)
|
||||
|
||||
func (cli *Client) NewComposeDeployment(ctx context.Context, project *types.Project) (*ComposeDeployment, error) {
|
||||
domain, err := cli.GetDomain(ctx)
|
||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||
return nil, fmt.Errorf("get cluster domain: %w", err)
|
||||
}
|
||||
|
||||
resolver := &ServiceSpecResolver{
|
||||
// If the domain is not found (not reserved), an empty domain is used for the resolver.
|
||||
ClusterDomain: domain,
|
||||
// TODO: provide an image resolver.
|
||||
}
|
||||
|
||||
return &ComposeDeployment{
|
||||
Client: cli,
|
||||
Project: project,
|
||||
SpecResolver: resolver,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ComposeDeployment struct {
|
||||
Client *Client
|
||||
Project *types.Project
|
||||
SpecResolver *ServiceSpecResolver
|
||||
plan *SequenceOperation
|
||||
}
|
||||
|
||||
func (d *ComposeDeployment) Plan(ctx context.Context) (SequenceOperation, error) {
|
||||
if d.plan != nil {
|
||||
return *d.plan, nil
|
||||
}
|
||||
|
||||
plan := SequenceOperation{}
|
||||
err := graph.InDependencyOrder(ctx, d.Project,
|
||||
func(ctx context.Context, name string, _ types.ServiceConfig) error {
|
||||
spec, err := d.ServiceSpec(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert compose service '%s' to service spec: %w", name, err)
|
||||
}
|
||||
|
||||
// TODO: properly handle dependency conditions in the service deployment plan as the first operation.
|
||||
deploy, err := d.Client.NewDeployment(spec, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create deployment for service '%s': %w", name, err)
|
||||
}
|
||||
|
||||
servicePlan, err := deploy.Plan(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create deployment plan for service '%s': %w", name, err)
|
||||
}
|
||||
|
||||
// Skip no-op (up-to-date) service plans.
|
||||
if len(servicePlan.Operations) > 0 {
|
||||
plan.Operations = append(plan.Operations, &servicePlan)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
d.plan = &plan
|
||||
}
|
||||
|
||||
return plan, err
|
||||
}
|
||||
|
||||
// ServiceSpec returns the service specification for the given compose service that is ready for deployment.
|
||||
func (d *ComposeDeployment) ServiceSpec(name string) (api.ServiceSpec, error) {
|
||||
service, err := d.Project.GetService(name)
|
||||
if err != nil {
|
||||
return api.ServiceSpec{}, fmt.Errorf("get config for compose service '%s': %w", name, err)
|
||||
}
|
||||
|
||||
spec, err := compose.ServiceSpecFromCompose(name, service)
|
||||
if err != nil {
|
||||
return spec, fmt.Errorf("convert compose service '%s' to service spec: %w", name, err)
|
||||
}
|
||||
|
||||
// TODO: resolve the image to a digest and supported platforms using an image resolver that broadcasts requests
|
||||
// to all machines in the cluster. If service.PullPolicy is "missing":
|
||||
// - Broadcast request if any machine contains a particular image and resolve it to image@digest.
|
||||
// - If not found, broadcast request to resolve an image using a registry, and resolve it to image@digest.
|
||||
// TODO: configure placement filter based on the supported platforms of the image.
|
||||
if err = d.SpecResolver.Resolve(&spec); err != nil {
|
||||
return spec, fmt.Errorf("resolve service spec '%s': %w", name, err)
|
||||
}
|
||||
|
||||
// TODO: maybe instantiate ImageResolver here based on PullPolicy of each service?
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func (d *ComposeDeployment) Run(ctx context.Context) error {
|
||||
plan, err := d.Plan(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create plan: %w", err)
|
||||
}
|
||||
|
||||
return plan.Execute(ctx, d.Client)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/sshexec"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SSHConnectorConfig struct {
|
||||
User string
|
||||
Host string
|
||||
Port int
|
||||
KeyPath string
|
||||
|
||||
SockPath string
|
||||
}
|
||||
|
||||
// SSHConnector establishes a connection to the machine API through an SSH tunnel to the machine.
|
||||
type SSHConnector struct {
|
||||
config SSHConnectorConfig
|
||||
client *ssh.Client
|
||||
}
|
||||
|
||||
func NewSSHConnector(cfg *SSHConnectorConfig) *SSHConnector {
|
||||
return &SSHConnector{config: *cfg}
|
||||
}
|
||||
|
||||
func NewSSHConnectorFromClient(client *ssh.Client) *SSHConnector {
|
||||
return &SSHConnector{client: client}
|
||||
}
|
||||
|
||||
// TODO: handle context cancelation.
|
||||
func (c *SSHConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
|
||||
if c.client == nil {
|
||||
// Establish an SSH connection if the SSH client is not provided.
|
||||
if c.config == (SSHConnectorConfig{}) {
|
||||
return nil, fmt.Errorf("SSH connector not configured")
|
||||
}
|
||||
var err error
|
||||
c.client, err = sshexec.Connect(c.config.User, c.config.Host, c.config.Port, c.config.KeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH login to %s@%s:%d: %w", c.config.User, c.config.Host, c.config.Port, err)
|
||||
}
|
||||
}
|
||||
|
||||
sockPath := c.config.SockPath
|
||||
if sockPath == "" {
|
||||
sockPath = machine.DefaultUncloudSockPath
|
||||
}
|
||||
conn, err := grpc.NewClient(
|
||||
"unix://"+sockPath,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithContextDialer(
|
||||
func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
addr = strings.TrimPrefix(addr, "unix://")
|
||||
conn, dErr := c.client.DialContext(ctx, "unix", addr)
|
||||
if dErr != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"connect to machine API socket '%s' through SSH tunnel (is the Uncloud daemon running "+
|
||||
"on the remote machine and does the SSH user '%s' have permissions to access the socket?):"+
|
||||
" %w",
|
||||
addr, c.client.User(), dErr,
|
||||
)
|
||||
}
|
||||
return conn, nil
|
||||
},
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create machine API client: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (c *SSHConnector) Close() error {
|
||||
if c.client != nil {
|
||||
err := c.client.Close()
|
||||
c.client = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// TCPConnector establishes a connection to the machine API through a direct TCP connection to an API endpoint.
|
||||
type TCPConnector struct {
|
||||
apiAddr netip.AddrPort
|
||||
}
|
||||
|
||||
func NewTCPConnector(apiAddr netip.AddrPort) *TCPConnector {
|
||||
return &TCPConnector{apiAddr: apiAddr}
|
||||
}
|
||||
|
||||
func (c *TCPConnector) Connect(_ context.Context) (*grpc.ClientConn, error) {
|
||||
conn, err := grpc.NewClient(
|
||||
c.apiAddr.String(),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create machine API client: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (c *TCPConnector) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"github.com/psviderski/uncloud/internal/cli/client"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
machine2 "github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/network/tunnel"
|
||||
)
|
||||
|
||||
// WireGuardConnector establishes a connection to the cluster API through a WireGuard tunnel
|
||||
// to one of the cluster machines.
|
||||
type WireGuardConnector struct {
|
||||
user *client.User
|
||||
machines []config.MachineConnection
|
||||
tun *tunnel.Tunnel
|
||||
}
|
||||
|
||||
func NewWireGuardConnector(user *client.User, machines []config.MachineConnection) *WireGuardConnector {
|
||||
return &WireGuardConnector{
|
||||
user: user,
|
||||
machines: machines,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: handle context cancelation.
|
||||
func (c *WireGuardConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
|
||||
if len(c.machines) == 0 {
|
||||
return nil, fmt.Errorf("no machines to connect to")
|
||||
}
|
||||
// TODO: iterate over machines and try to connect to each one until successful.
|
||||
// For now, try to connect to only the first machine.
|
||||
machine := c.machines[0]
|
||||
endpointIPs, err := net.LookupIP(machine.Host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve IP for %q: %w", machine.Host, err)
|
||||
}
|
||||
endpointAddr, err := netip.ParseAddr(endpointIPs[0].String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse IP address %q: %w", endpointIPs[0].String(), err)
|
||||
}
|
||||
endpoint := netip.AddrPortFrom(endpointAddr, tunnel.DefaultEndpointPort)
|
||||
machineManagementIP := network.ManagementIP(machine.PublicKey)
|
||||
machineAPIAddr := net.JoinHostPort(machineManagementIP.String(), strconv.Itoa(machine2.APIPort))
|
||||
|
||||
tunCfg := &tunnel.Config{
|
||||
LocalAddress: c.user.ManagementIP(),
|
||||
LocalPrivateKey: c.user.PrivateKey(),
|
||||
RemotePublicKey: machine.PublicKey,
|
||||
RemoteNetwork: netip.PrefixFrom(machineManagementIP, 128),
|
||||
Endpoint: endpoint,
|
||||
}
|
||||
if c.tun, err = tunnel.Connect(tunCfg); err != nil {
|
||||
return nil, fmt.Errorf("establish WireGuard tunnel to %q: %w", endpoint, err)
|
||||
}
|
||||
|
||||
conn, err := grpc.NewClient(
|
||||
machineAPIAddr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
return c.tun.DialContext(ctx, "tcp", addr)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to machine API through WireGuard tunnel: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (c *WireGuardConnector) Close() error {
|
||||
if c.tun != nil {
|
||||
c.tun.Close()
|
||||
c.tun = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"google.golang.org/grpc/status"
|
||||
"strconv"
|
||||
"strings"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
)
|
||||
|
||||
// CreateContainer creates a new container for the given service on the specified machine.
|
||||
func (cli *Client) CreateContainer(
|
||||
ctx context.Context, serviceID string, spec api.ServiceSpec, machineID string,
|
||||
) (container.CreateResponse, error) {
|
||||
var resp container.CreateResponse
|
||||
|
||||
if !api.ValidateServiceID(serviceID) {
|
||||
return resp, fmt.Errorf("invalid service ID: '%s'", serviceID)
|
||||
}
|
||||
// TODO: validate spec.Name is consistent with serviceID if this is not the first container in the service.
|
||||
|
||||
machine, err := cli.InspectMachine(ctx, machineID)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("inspect machine '%s': %w", machineID, err)
|
||||
}
|
||||
|
||||
suffix, err := secret.RandomAlphaNumeric(4)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("generate random suffix: %w", err)
|
||||
}
|
||||
containerName := fmt.Sprintf("%s-%s", spec.Name, suffix)
|
||||
|
||||
specHash, err := spec.ImmutableHash()
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("calculate immutable hash for service spec: %w", err)
|
||||
}
|
||||
|
||||
config := &container.Config{
|
||||
Cmd: spec.Container.Command,
|
||||
Entrypoint: spec.Container.Entrypoint,
|
||||
Hostname: containerName,
|
||||
Image: spec.Container.Image,
|
||||
Labels: map[string]string{
|
||||
api.LabelServiceID: serviceID,
|
||||
api.LabelServiceName: spec.Name,
|
||||
api.LabelServiceMode: spec.Mode,
|
||||
api.LabelServiceSpecHash: specHash,
|
||||
api.LabelManaged: "",
|
||||
},
|
||||
}
|
||||
if spec.Mode == "" {
|
||||
config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated
|
||||
}
|
||||
|
||||
if len(spec.Ports) > 0 {
|
||||
encodedPorts := make([]string, len(spec.Ports))
|
||||
for i, p := range spec.Ports {
|
||||
encodedPorts[i], err = p.String()
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("encode service port spec: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
config.Labels[api.LabelServicePorts] = strings.Join(encodedPorts, ",")
|
||||
}
|
||||
|
||||
portBindings := make(nat.PortMap)
|
||||
for _, p := range spec.Ports {
|
||||
if p.Mode != api.PortModeHost {
|
||||
continue
|
||||
}
|
||||
port := nat.Port(fmt.Sprintf("%d/%s", p.ContainerPort, p.Protocol))
|
||||
portBindings[port] = []nat.PortBinding{
|
||||
{
|
||||
HostPort: strconv.Itoa(int(p.PublishedPort)),
|
||||
},
|
||||
}
|
||||
if p.HostIP.IsValid() {
|
||||
portBindings[port][0].HostIP = p.HostIP.String()
|
||||
}
|
||||
}
|
||||
hostConfig := &container.HostConfig{
|
||||
Binds: spec.Container.Volumes,
|
||||
Init: spec.Container.Init,
|
||||
PortBindings: portBindings,
|
||||
// Always restart service containers if they exit or a machine restarts.
|
||||
// For one-off containers and batch jobs we plan to use a different service type/mode.
|
||||
RestartPolicy: container.RestartPolicy{
|
||||
Name: container.RestartPolicyAlways,
|
||||
},
|
||||
}
|
||||
netConfig := &network.NetworkingConfig{
|
||||
EndpointsConfig: map[string]*network.EndpointSettings{
|
||||
machinedocker.NetworkName: {},
|
||||
},
|
||||
}
|
||||
|
||||
// Proxy Docker gRPC requests to the selected machine.
|
||||
ctx = proxyToMachine(ctx, machine.Machine)
|
||||
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Container %s on %s", containerName, machine.Machine.Name)
|
||||
|
||||
pw.Event(progress.CreatingEvent(eventID))
|
||||
resp, err = cli.Docker.CreateContainer(ctx, config, hostConfig, netConfig, nil, containerName)
|
||||
if err != nil {
|
||||
if !dockerclient.IsErrNotFound(err) {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// Pull the missing image and create the container again.
|
||||
if err = cli.pullImageWithProgress(ctx, config.Image, machine.Machine.Name, eventID); err != nil {
|
||||
return resp, err
|
||||
}
|
||||
if resp, err = cli.Docker.CreateContainer(ctx, config, hostConfig, netConfig, nil, containerName); err != nil {
|
||||
return resp, err
|
||||
}
|
||||
}
|
||||
pw.Event(progress.CreatedEvent(eventID))
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (cli *Client) pullImageWithProgress(ctx context.Context, image, machineName, parentEventID string) error {
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Image %s on %s", image, machineName)
|
||||
pw.Event(progress.Event{
|
||||
ID: eventID,
|
||||
ParentID: parentEventID,
|
||||
Status: progress.Working,
|
||||
StatusText: "Pulling",
|
||||
})
|
||||
|
||||
pullCh, err := cli.Docker.PullImage(ctx, image)
|
||||
if err != nil {
|
||||
statusErr := status.Convert(err)
|
||||
pw.Event(progress.Event{
|
||||
ID: eventID,
|
||||
ParentID: parentEventID,
|
||||
Text: "Error",
|
||||
Status: progress.Error,
|
||||
StatusText: statusErr.Message(),
|
||||
})
|
||||
return fmt.Errorf("pull image: %w", errors.New(statusErr.Message()))
|
||||
}
|
||||
|
||||
// Wait for pull to complete by reading all progress messages and converting them to events.
|
||||
for msg := range pullCh {
|
||||
if msg.Err != nil {
|
||||
err = msg.Err
|
||||
} else {
|
||||
if msg.Message.Error != nil {
|
||||
err = errors.New(msg.Message.Error.Message)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
statusErr := status.Convert(err)
|
||||
pw.Event(progress.Event{
|
||||
ID: eventID,
|
||||
ParentID: parentEventID,
|
||||
Text: "Error",
|
||||
Status: progress.Error,
|
||||
StatusText: statusErr.Message(),
|
||||
})
|
||||
return fmt.Errorf("pull image: %w", errors.New(statusErr.Message()))
|
||||
}
|
||||
|
||||
// TODO: add like in compose: --quiet-pull Pull without printing progress information
|
||||
e := toPullProgressEvent(msg.Message)
|
||||
if e != nil {
|
||||
e.ID = fmt.Sprintf("%s on %s", e.ID, machineName)
|
||||
e.ParentID = eventID
|
||||
// Grand children events are not printed by the tty progress writer but they are still required
|
||||
// to calculate the progress line of their parent.
|
||||
pw.Event(*e)
|
||||
}
|
||||
}
|
||||
pw.Event(progress.Event{
|
||||
ID: eventID,
|
||||
ParentID: parentEventID,
|
||||
Status: progress.Done,
|
||||
StatusText: "Pulled",
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// toPullProgressEvent converts a JSON progress message from the Docker API to a progress event.
|
||||
// It's based on toPullProgressEvent from Docker Compose.
|
||||
func toPullProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
|
||||
if jm.ID == "" || jm.Progress == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
total int64
|
||||
percent int
|
||||
current int64
|
||||
)
|
||||
text := jm.Progress.String()
|
||||
stat := progress.Working
|
||||
|
||||
switch jm.Status {
|
||||
case "Preparing", "Waiting", "Pulling fs layer":
|
||||
percent = 0
|
||||
case "Downloading", "Extracting", "Verifying Checksum":
|
||||
current = jm.Progress.Current
|
||||
total = jm.Progress.Total
|
||||
if jm.Progress.Total > 0 {
|
||||
percent = int(jm.Progress.Current * 100 / jm.Progress.Total)
|
||||
}
|
||||
case "Download complete", "Already exists", "Pull complete":
|
||||
stat = progress.Done
|
||||
percent = 100
|
||||
}
|
||||
|
||||
if strings.Contains(jm.Status, "Image is up to date") ||
|
||||
strings.Contains(jm.Status, "Downloaded newer image") {
|
||||
stat = progress.Done
|
||||
percent = 100
|
||||
}
|
||||
|
||||
return &progress.Event{
|
||||
ID: jm.ID,
|
||||
Current: current,
|
||||
Total: total,
|
||||
Percent: percent,
|
||||
Text: jm.Status,
|
||||
Status: stat,
|
||||
StatusText: text,
|
||||
}
|
||||
}
|
||||
|
||||
// InspectContainer returns the information about the specified container within the service.
|
||||
func (cli *Client) InspectContainer(ctx context.Context, serviceID, containerID string) (api.MachineContainer, error) {
|
||||
var ctr api.MachineContainer
|
||||
|
||||
svc, err := cli.InspectService(ctx, serviceID)
|
||||
if err != nil {
|
||||
return ctr, fmt.Errorf("inspect service: %w", err)
|
||||
}
|
||||
|
||||
for _, c := range svc.Containers {
|
||||
if c.Container.ID == containerID || c.Container.NameWithoutSlash() == containerID {
|
||||
ctr = c
|
||||
}
|
||||
}
|
||||
if ctr.MachineID == "" {
|
||||
return ctr, ErrNotFound
|
||||
}
|
||||
|
||||
return ctr, nil
|
||||
}
|
||||
|
||||
// StartContainer starts the specified container within the service.
|
||||
func (cli *Client) StartContainer(ctx context.Context, serviceID, containerID string) error {
|
||||
ctr, err := cli.InspectContainer(ctx, serviceID, containerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
machine, err := cli.InspectMachine(ctx, ctr.MachineID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect machine '%s': %w", ctr.MachineID, err)
|
||||
}
|
||||
ctx = proxyToMachine(ctx, machine.Machine)
|
||||
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Container %s on %s", ctr.Container.NameWithoutSlash(), machine.Machine.Name)
|
||||
|
||||
pw.Event(progress.StartingEvent(eventID))
|
||||
if err = cli.Docker.StartContainer(ctx, ctr.Container.ID, container.StartOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
pw.Event(progress.StartedEvent(eventID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopContainer stops the specified container within the service.
|
||||
func (cli *Client) StopContainer(
|
||||
ctx context.Context, serviceID, containerID string, opts container.StopOptions,
|
||||
) error {
|
||||
ctr, err := cli.InspectContainer(ctx, serviceID, containerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
machine, err := cli.InspectMachine(ctx, ctr.MachineID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect machine '%s': %w", ctr.MachineID, err)
|
||||
}
|
||||
ctx = proxyToMachine(ctx, machine.Machine)
|
||||
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Container %s on %s", ctr.Container.NameWithoutSlash(), machine.Machine.Name)
|
||||
|
||||
pw.Event(progress.StoppingEvent(eventID))
|
||||
if err = cli.Docker.StopContainer(ctx, ctr.Container.ID, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
pw.Event(progress.StoppedEvent(eventID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveContainer removes the specified container within the service.
|
||||
func (cli *Client) RemoveContainer(
|
||||
ctx context.Context, serviceID, containerID string, opts container.RemoveOptions,
|
||||
) error {
|
||||
ctr, err := cli.InspectContainer(ctx, serviceID, containerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
machine, err := cli.InspectMachine(ctx, ctr.MachineID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect machine '%s': %w", ctr.MachineID, err)
|
||||
}
|
||||
ctx = proxyToMachine(ctx, machine.Machine)
|
||||
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Container %s on %s", ctr.Container.NameWithoutSlash(), machine.Machine.Name)
|
||||
|
||||
pw.Event(progress.RemovingEvent(eventID))
|
||||
if err = cli.Docker.RemoveContainer(ctx, ctr.Container.ID, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
pw.Event(progress.RemovedEvent(eventID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ContainerSpecStatus string
|
||||
|
||||
const ContainerUpToDate ContainerSpecStatus = "up-to-date"
|
||||
const ContainerNeedsUpdate ContainerSpecStatus = "needs-update"
|
||||
const ContainerNeedsRecreate ContainerSpecStatus = "needs-recreate"
|
||||
|
||||
func CompareContainerToSpec(ctr api.Container, spec api.ServiceSpec) (ContainerSpecStatus, error) {
|
||||
specHash, err := spec.ImmutableHash()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("calculate immutable hash for service spec: %w", err)
|
||||
}
|
||||
|
||||
// Is the hash label is unset, there is no easy way to compare its configuration with the spec,
|
||||
// so let's recreate as well.
|
||||
if ctr.Config.Labels[api.LabelServiceSpecHash] != specHash {
|
||||
return ContainerNeedsRecreate, nil
|
||||
}
|
||||
|
||||
// TODO: compare mutable properties such as memory or CPU limits when they are implemented.
|
||||
|
||||
return ContainerUpToDate, nil
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
// Deployment manages the process of creating or updating a service to match a desired state.
|
||||
// It coordinates the validation, planning, and execution of deployment operations.
|
||||
type Deployment struct {
|
||||
Service *api.Service
|
||||
Spec api.ServiceSpec
|
||||
Strategy Strategy
|
||||
cli *Client
|
||||
plan *Plan
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
ServiceID string
|
||||
ServiceName string
|
||||
SequenceOperation
|
||||
}
|
||||
|
||||
// MachineFilter determines which machines participate in a deployment operation by returning true for
|
||||
// machines that should be included.
|
||||
type MachineFilter func(m *pb.MachineInfo) bool
|
||||
|
||||
var ErrNoMatchingMachines = errors.New("no machines match the filter")
|
||||
|
||||
// NewDeployment creates a new deployment for the given service specification.
|
||||
// If strategy is nil, a default RollingStrategy will be used.
|
||||
// TODO(refactor): do not return error
|
||||
func (cli *Client) NewDeployment(spec api.ServiceSpec, strategy Strategy) (*Deployment, error) {
|
||||
if strategy == nil {
|
||||
strategy = &RollingStrategy{}
|
||||
}
|
||||
|
||||
return &Deployment{
|
||||
Spec: spec,
|
||||
Strategy: strategy,
|
||||
cli: cli,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Plan returns a plan of operations to reconcile the service to the desired state.
|
||||
// If a plan has already been created, the same plan will be returned.
|
||||
func (d *Deployment) Plan(ctx context.Context) (Plan, error) {
|
||||
if d.plan != nil {
|
||||
return *d.plan, nil
|
||||
}
|
||||
|
||||
// Validate the new spec before planning.
|
||||
if err := d.Validate(ctx); err != nil {
|
||||
return Plan{}, fmt.Errorf("invalid deployment: %w", err)
|
||||
}
|
||||
|
||||
plan, err := d.Strategy.Plan(ctx, d.cli, d.Service, d.Spec)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("create plan using %s strategy: %w", d.Strategy.Type(), err)
|
||||
}
|
||||
d.plan = &plan
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// Validate checks if the deployment specification is valid.
|
||||
func (d *Deployment) Validate(ctx context.Context) error {
|
||||
if err := d.Spec.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid service spec: %w", err)
|
||||
}
|
||||
if d.Spec.Name == "" {
|
||||
return errors.New("service name is required")
|
||||
}
|
||||
|
||||
if d.Service == nil {
|
||||
svc, err := d.cli.InspectService(ctx, d.Spec.Name)
|
||||
if err == nil {
|
||||
d.Service = &svc
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return fmt.Errorf("inspect service: %w", err)
|
||||
}
|
||||
}
|
||||
// d.Service is nil if the service doesn't exist yet (first deployment).
|
||||
if d.Service == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if d.Service.Name != d.Spec.Name {
|
||||
return errors.New("service name cannot be changed")
|
||||
}
|
||||
if d.Service.Mode != d.Spec.Mode {
|
||||
return errors.New("service mode cannot be changed")
|
||||
}
|
||||
if d.Spec.Mode == api.ServiceModeReplicated && d.Spec.Replicas < 1 {
|
||||
return errors.New("number of replicas must be at least 1")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run executes the deployment plan and returns the ID of the created or updated service.
|
||||
// It will create a new plan if one hasn't been created yet. The deployment will either create a new service or update
|
||||
// the existing one to match the desired specification.
|
||||
// TODO: forbid to run the same deployment more than once.
|
||||
func (d *Deployment) Run(ctx context.Context) (Plan, error) {
|
||||
plan, err := d.Plan(ctx)
|
||||
if err != nil {
|
||||
return plan, fmt.Errorf("create plan: %w", err)
|
||||
}
|
||||
|
||||
return plan, plan.Execute(ctx, d.cli)
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/caddyfile"
|
||||
)
|
||||
|
||||
// GetDomain returns the cluster domain name or ErrNotFound if it hasn't been reserved yet.
|
||||
func (cli *Client) GetDomain(ctx context.Context) (string, error) {
|
||||
domain, err := cli.ClusterClient.GetDomain(ctx, nil)
|
||||
if err != nil {
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return domain.Name, nil
|
||||
}
|
||||
|
||||
var ErrNoReachableMachines = errors.New("no internet-reachable machines running service containers")
|
||||
|
||||
// CreateIngressRecords verifies which machines running the specified service (typically Caddy) are reachable from
|
||||
// the internet, then creates DNS records for the cluster domain pointing to those machines. It tests each machine
|
||||
// by sending HTTP requests to their public IPs. Only machines that respond correctly with their machine ID are included
|
||||
// in the resulting DNS configuration. Returns the created DNS records or an error.
|
||||
func (cli *Client) CreateIngressRecords(ctx context.Context, serviceID string) ([]*pb.DNSRecord, error) {
|
||||
svc, err := cli.InspectService(ctx, serviceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect service '%s': %w", serviceID, err)
|
||||
}
|
||||
|
||||
machineIDs := make(map[string]struct{}, len(svc.Containers))
|
||||
for _, mc := range svc.Containers {
|
||||
machineIDs[mc.MachineID] = struct{}{}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
reachableMachines := make(chan *pb.MachineInfo)
|
||||
|
||||
for id := range machineIDs {
|
||||
m, err := cli.InspectMachine(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect machine '%s': %w", id, err)
|
||||
}
|
||||
|
||||
if m.Machine.PublicIp == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
if err = verifyCaddyReachable(ctx, m.Machine); err == nil {
|
||||
reachableMachines <- m.Machine
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(reachableMachines)
|
||||
}()
|
||||
|
||||
var ingressIPs []string
|
||||
for m := range reachableMachines {
|
||||
ip, _ := m.PublicIp.ToAddr()
|
||||
ingressIPs = append(ingressIPs, ip.String())
|
||||
}
|
||||
if len(ingressIPs) == 0 {
|
||||
return nil, ErrNoReachableMachines
|
||||
}
|
||||
|
||||
req := &pb.CreateDomainRecordsRequest{
|
||||
Records: []*pb.DNSRecord{
|
||||
{
|
||||
Name: "*",
|
||||
Type: pb.DNSRecord_A,
|
||||
Values: ingressIPs,
|
||||
},
|
||||
// TODO: Add AAAA record with routable IPv6 addresses of machines running Caddy containers.
|
||||
},
|
||||
}
|
||||
resp, err := cli.CreateDomainRecords(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create cluster domain records in Uncloud DNS: %w", err)
|
||||
}
|
||||
|
||||
return resp.Records, nil
|
||||
}
|
||||
|
||||
// verifyCaddyReachable verifies that the Caddy service is reachable on the machine by its public IP.
|
||||
func verifyCaddyReachable(ctx context.Context, m *pb.MachineInfo) error {
|
||||
publicIP, _ := m.PublicIp.ToAddr()
|
||||
|
||||
pw := progress.ContextWriter(ctx)
|
||||
eventID := fmt.Sprintf("Machine %s (%s)", m.Name, publicIP)
|
||||
pw.Event(progress.NewEvent(eventID, progress.Working, "Querying"))
|
||||
|
||||
verifyURL := fmt.Sprintf("http://%s%s", publicIP, caddyfile.VerifyPath)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, verifyURL, nil)
|
||||
if err != nil {
|
||||
pw.Event(progress.NewEvent(eventID, progress.Error, err.Error()))
|
||||
return err
|
||||
}
|
||||
|
||||
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
||||
backoff.WithMaxInterval(1*time.Second),
|
||||
backoff.WithMaxElapsedTime(5*time.Second),
|
||||
), ctx)
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
do := func() (*http.Response, error) {
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
resp, err := backoff.RetryWithData(do, boff)
|
||||
if err != nil {
|
||||
e := unreachable(eventID)
|
||||
e.Text = fmt.Sprintf("Failed to send HTTP request: %v", err)
|
||||
pw.Event(e)
|
||||
|
||||
return fmt.Errorf("send HTTP request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
e := unreachable(eventID)
|
||||
e.Text = fmt.Sprintf("Unexpected HTTP response status code: %d", resp.StatusCode)
|
||||
pw.Event(e)
|
||||
|
||||
return fmt.Errorf("unexpected HTTP response status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
e := unreachable(eventID)
|
||||
e.Text = fmt.Sprintf("Failed to read HTTP response body: %v", err)
|
||||
pw.Event(e)
|
||||
|
||||
return fmt.Errorf("read HTTP response body: %w", err)
|
||||
}
|
||||
|
||||
// Check the response body is the machine ID to ensure the correct Caddy container is responding.
|
||||
if string(body) == m.Id {
|
||||
pw.Event(progress.NewEvent(eventID, progress.Done, "Reachable"))
|
||||
return nil
|
||||
} else {
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 50 {
|
||||
bodyStr = bodyStr[:50] + "..."
|
||||
}
|
||||
|
||||
e := unreachable(eventID)
|
||||
e.Text = fmt.Sprintf("Unexpected HTTP response body: %s", bodyStr)
|
||||
pw.Event(e)
|
||||
|
||||
return fmt.Errorf("unexpected HTTP response body: %s", bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
// unreachable creates a new Unreachable error event.
|
||||
func unreachable(id string) progress.Event {
|
||||
return progress.NewEvent(
|
||||
id,
|
||||
progress.Error,
|
||||
"Unreachable (probably behind NAT or firewall)",
|
||||
)
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"strings"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
)
|
||||
|
||||
// Operation represents a single atomic operation in a deployment process.
|
||||
// Operations can be composed to form complex deployment strategies.
|
||||
type Operation interface {
|
||||
// Execute performs the operation using the provided client.
|
||||
// TODO: Encapsulate the client in the operation as otherwise it gives an impression that different clients
|
||||
// can be provided. But in reality, the operation is tightly coupled with the client that was used to create it.
|
||||
Execute(ctx context.Context, cli *Client) error
|
||||
// Format returns a human-readable representation of the operation.
|
||||
Format(resolver NameResolver) string
|
||||
String() string
|
||||
}
|
||||
|
||||
// NameResolver resolves machine and container IDs to their names.
|
||||
type NameResolver interface {
|
||||
MachineName(machineID string) string
|
||||
ContainerName(containerID string) string
|
||||
}
|
||||
|
||||
// RunContainerOperation creates and starts a new container on a specific machine.
|
||||
type RunContainerOperation struct {
|
||||
ServiceID string
|
||||
Spec api.ServiceSpec
|
||||
MachineID string
|
||||
}
|
||||
|
||||
func (o *RunContainerOperation) Execute(ctx context.Context, cli *Client) error {
|
||||
resp, err := cli.CreateContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create container: %w", err)
|
||||
}
|
||||
if err = cli.StartContainer(ctx, o.ServiceID, resp.ID); err != nil {
|
||||
return fmt.Errorf("start container: %w", err)
|
||||
}
|
||||
|
||||
// TODO: wait for the container to become healthy
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *RunContainerOperation) Format(resolver NameResolver) string {
|
||||
machineName := resolver.MachineName(o.MachineID)
|
||||
return fmt.Sprintf("%s: Run container [image=%s]", machineName, o.Spec.Container.Image)
|
||||
}
|
||||
|
||||
func (o *RunContainerOperation) String() string {
|
||||
return fmt.Sprintf("RunContainerOperation[service_id=%s, image=%s, machine_id=%s]",
|
||||
o.ServiceID, o.Spec.Container.Image, o.MachineID)
|
||||
}
|
||||
|
||||
// StopContainerOperation stops a container on a specific machine.
|
||||
type StopContainerOperation struct {
|
||||
ServiceID string
|
||||
ContainerID string
|
||||
MachineID string
|
||||
}
|
||||
|
||||
func (o *StopContainerOperation) Execute(ctx context.Context, cli *Client) error {
|
||||
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, container.StopOptions{}); err != nil {
|
||||
return fmt.Errorf("stop container: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *StopContainerOperation) Format(resolver NameResolver) string {
|
||||
machineName := resolver.MachineName(o.MachineID)
|
||||
return fmt.Sprintf("%s: Stop container [name=%s]", machineName, resolver.ContainerName(o.ContainerID))
|
||||
}
|
||||
|
||||
func (o *StopContainerOperation) String() string {
|
||||
return fmt.Sprintf("StopContainerOperation[service_id=%s, container_id=%s, machine_id=%s]",
|
||||
o.ServiceID, o.ContainerID, o.MachineID)
|
||||
}
|
||||
|
||||
// RemoveContainerOperation stops and removes a container from a specific machine.
|
||||
type RemoveContainerOperation struct {
|
||||
ServiceID string
|
||||
ContainerID string
|
||||
MachineID string
|
||||
}
|
||||
|
||||
func (o *RemoveContainerOperation) Execute(ctx context.Context, cli *Client) error {
|
||||
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, container.StopOptions{}); err != nil {
|
||||
return fmt.Errorf("stop container: %w", err)
|
||||
}
|
||||
if err := cli.RemoveContainer(ctx, o.ServiceID, o.ContainerID, container.RemoveOptions{}); err != nil {
|
||||
return fmt.Errorf("remove container: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *RemoveContainerOperation) Format(resolver NameResolver) string {
|
||||
machineName := resolver.MachineName(o.MachineID)
|
||||
return fmt.Sprintf("%s: Remove container [name=%s]", machineName, resolver.ContainerName(o.ContainerID))
|
||||
}
|
||||
|
||||
func (o *RemoveContainerOperation) String() string {
|
||||
return fmt.Sprintf("RemoveContainerOperation[service_id=%s, container_id=%s, machine_id=%s]",
|
||||
o.ServiceID, o.ContainerID, o.MachineID)
|
||||
}
|
||||
|
||||
// SequenceOperation is a composite operation that executes a sequence of operations in order.
|
||||
type SequenceOperation struct {
|
||||
Operations []Operation
|
||||
}
|
||||
|
||||
func (o *SequenceOperation) Execute(ctx context.Context, cli *Client) error {
|
||||
for _, op := range o.Operations {
|
||||
if err := op.Execute(ctx, cli); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *SequenceOperation) Format(resolver NameResolver) string {
|
||||
ops := make([]string, len(o.Operations))
|
||||
for i, op := range o.Operations {
|
||||
ops[i] = "- " + op.Format(resolver)
|
||||
}
|
||||
|
||||
return strings.Join(ops, "\n")
|
||||
}
|
||||
|
||||
func (o *SequenceOperation) String() string {
|
||||
ops := make([]string, len(o.Operations))
|
||||
for i, op := range o.Operations {
|
||||
ops[i] = op.String()
|
||||
}
|
||||
|
||||
return fmt.Sprintf("SequenceOperation[%s]", strings.Join(ops, ", "))
|
||||
}
|
||||
|
||||
// MapNameResolver resolves machine and container IDs to their names using a static map.
|
||||
type MapNameResolver struct {
|
||||
machines map[string]string
|
||||
containers map[string]string
|
||||
}
|
||||
|
||||
func NewNameResolver(machines, containers map[string]string) *MapNameResolver {
|
||||
return &MapNameResolver{
|
||||
machines: machines,
|
||||
containers: containers,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MapNameResolver) MachineName(machineID string) string {
|
||||
if name, ok := r.machines[machineID]; ok {
|
||||
return name
|
||||
}
|
||||
return machineID
|
||||
}
|
||||
|
||||
func (r *MapNameResolver) ContainerName(containerID string) string {
|
||||
if name, ok := r.containers[containerID]; ok {
|
||||
return name
|
||||
}
|
||||
return containerID
|
||||
}
|
||||
|
||||
// ServiceOperationNameResolver returns a machine and container name resolver for a service that can be used to format
|
||||
// deployment operations.
|
||||
func (cli *Client) ServiceOperationNameResolver(ctx context.Context, svc api.Service) (*MapNameResolver, error) {
|
||||
machines, err := cli.ListMachines(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
machineNames := make(map[string]string, len(machines))
|
||||
for _, m := range machines {
|
||||
machineNames[m.Machine.Id] = m.Machine.Name
|
||||
}
|
||||
containerNames := make(map[string]string, len(svc.Containers))
|
||||
for _, c := range svc.Containers {
|
||||
containerNames[c.Container.ID] = c.Container.NameWithoutSlash()
|
||||
}
|
||||
|
||||
return NewNameResolver(machineNames, containerNames), nil
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ImageDigestResolver interface {
|
||||
Resolve(image string) (string, error)
|
||||
}
|
||||
|
||||
// ServiceSpecResolver transforms user-provided service specs into deployment-ready form.
|
||||
type ServiceSpecResolver struct {
|
||||
ClusterDomain string
|
||||
ImageResolver ImageDigestResolver
|
||||
}
|
||||
|
||||
// Resolve transforms a service spec into its fully resolved form ready for deployment.
|
||||
func (r *ServiceSpecResolver) Resolve(spec *api.ServiceSpec) error {
|
||||
if err := spec.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid service spec: %w", err)
|
||||
}
|
||||
|
||||
steps := []func(*api.ServiceSpec) error{
|
||||
r.applyDefaults,
|
||||
r.resolveServiceName,
|
||||
r.resolveImageDigest,
|
||||
r.expandIngressPorts,
|
||||
}
|
||||
|
||||
for _, step := range steps {
|
||||
if err := step(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ServiceSpecResolver) applyDefaults(spec *api.ServiceSpec) error {
|
||||
if spec.Mode == "" {
|
||||
spec.Mode = api.ServiceModeReplicated
|
||||
}
|
||||
// Ensure the replicated service has at least one replica.
|
||||
if spec.Mode == api.ServiceModeReplicated && spec.Replicas == 0 {
|
||||
spec.Replicas = 1
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ServiceSpecResolver) resolveServiceName(spec *api.ServiceSpec) error {
|
||||
if spec.Name != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate a random service name from the image when not provided.
|
||||
img, err := reference.ParseDockerRef(spec.Container.Image)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid image: %w", err)
|
||||
}
|
||||
// Get the image name without the repository and tag/digest parts.
|
||||
imageName := reference.FamiliarName(img)
|
||||
// Get the last part of the image name (path), e.g. "nginx" from "bitnami/nginx".
|
||||
if i := strings.LastIndex(imageName, "/"); i != -1 {
|
||||
imageName = imageName[i+1:]
|
||||
}
|
||||
// Append a random suffix to the image name to generate an optimistically unique service name.
|
||||
suffix, err := secret.RandomAlphaNumeric(4)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate random suffix: %w", err)
|
||||
}
|
||||
spec.Name = fmt.Sprintf("%s-%s", imageName, suffix)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ServiceSpecResolver) resolveImageDigest(spec *api.ServiceSpec) error {
|
||||
if r.ImageResolver == nil {
|
||||
// Skip digest resolution when no resolver is provided.
|
||||
return nil
|
||||
}
|
||||
|
||||
imageDigest, err := r.ImageResolver.Resolve(spec.Container.Image)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve image digest: %w", err)
|
||||
}
|
||||
spec.Container.Image = imageDigest
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandIngressPorts processes HTTP(S) ingress ports in a service spec by:
|
||||
// 1. Setting a default hostname (service-name.cluster-domain) for ports without a hostname.
|
||||
// 2. Duplicating a port with a cluster domain hostname for ports with external domains.
|
||||
// This ensures every ingress port is accessible via the cluster domain, while preserving any custom domains specified
|
||||
// by the user.
|
||||
func (r *ServiceSpecResolver) expandIngressPorts(spec *api.ServiceSpec) error {
|
||||
for i, port := range spec.Ports {
|
||||
if port.Protocol != api.ProtocolHTTP && port.Protocol != api.ProtocolHTTPS {
|
||||
continue
|
||||
}
|
||||
|
||||
if port.Hostname == "" {
|
||||
if r.ClusterDomain == "" {
|
||||
return fmt.Errorf("cluster domain must be reserved to generate hostname for ingress port: %d/%s",
|
||||
port.ContainerPort, port.Protocol)
|
||||
}
|
||||
// Assign the default hostname (service-name.cluster-domain).
|
||||
spec.Ports[i].Hostname = fmt.Sprintf("%s.%s", spec.Name, r.ClusterDomain)
|
||||
} else {
|
||||
if r.ClusterDomain == "" {
|
||||
// When no cluster domain is reserved, use only the provided hostname.
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasSuffix(port.Hostname, "."+r.ClusterDomain) {
|
||||
// If the hostname is already a cluster subdomain, use as is.
|
||||
continue
|
||||
}
|
||||
// For external domains, duplicate the port with a service-name.cluster-domain hostname so the service
|
||||
// can be accessed via both hostnames.
|
||||
newPort := port
|
||||
newPort.Hostname = fmt.Sprintf("%s.%s", spec.Name, r.ClusterDomain)
|
||||
spec.Ports = append(spec.Ports, newPort)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
"slices"
|
||||
"sync"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
)
|
||||
|
||||
func (cli *Client) PrepareDeploymentSpec(ctx context.Context, spec api.ServiceSpec) (api.ServiceSpec, error) {
|
||||
domain, err := cli.GetDomain(ctx)
|
||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||
return spec, fmt.Errorf("get cluster domain: %w", err)
|
||||
}
|
||||
|
||||
resolver := ServiceSpecResolver{
|
||||
// If the domain is not found (not reserved), an empty domain is used for the resolver.
|
||||
ClusterDomain: domain,
|
||||
// TODO: provide an image resolver.
|
||||
}
|
||||
|
||||
if err = resolver.Resolve(&spec); err != nil {
|
||||
return spec, err
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
type RunServiceResponse struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (cli *Client) RunService(
|
||||
ctx context.Context, spec api.ServiceSpec, filter MachineFilter,
|
||||
) (RunServiceResponse, error) {
|
||||
var resp RunServiceResponse
|
||||
|
||||
if err := spec.Validate(); err != nil {
|
||||
return resp, fmt.Errorf("invalid service spec: %w", err)
|
||||
}
|
||||
|
||||
if spec.Name != "" {
|
||||
// Optimistically check if a service with the specified name already exists.
|
||||
_, err := cli.InspectService(ctx, spec.Name)
|
||||
if err == nil {
|
||||
return resp, fmt.Errorf("service with name '%s' already exists", spec.Name)
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return resp, fmt.Errorf("inspect service: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
if spec, err = cli.PrepareDeploymentSpec(ctx, spec); err != nil {
|
||||
return resp, fmt.Errorf("prepare service spec ready for deployment: %w", err)
|
||||
}
|
||||
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
deploy, err := cli.NewDeployment(spec, &RollingStrategy{MachineFilter: filter})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create deployment: %w", err)
|
||||
}
|
||||
|
||||
plan, err := deploy.Run(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp.ID = plan.ServiceID
|
||||
resp.Name = plan.ServiceName
|
||||
|
||||
return nil
|
||||
}, cli.progressOut(), fmt.Sprintf("Running service %s (%s mode)", spec.Name, spec.Mode))
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// InspectService returns detailed information about a service and its containers.
|
||||
// The id parameter can be either a service ID or name.
|
||||
func (cli *Client) InspectService(ctx context.Context, id string) (api.Service, error) {
|
||||
var svc api.Service
|
||||
|
||||
machines, err := cli.ListMachines(ctx)
|
||||
if err != nil {
|
||||
return svc, fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
|
||||
// Broadcast the container list request to all available machines.
|
||||
machineIDByManagementIP := make(map[string]string)
|
||||
md := metadata.New(nil)
|
||||
for _, m := range machines {
|
||||
if m.State == pb.MachineMember_UP || m.State == pb.MachineMember_SUSPECT {
|
||||
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
|
||||
md.Append("machines", machineIP.String())
|
||||
|
||||
machineIDByManagementIP[machineIP.String()] = m.Machine.Id
|
||||
}
|
||||
// TODO: warning about machines that are DOWN.
|
||||
}
|
||||
listCtx := metadata.NewOutgoingContext(ctx, md)
|
||||
|
||||
// List only uncloud-managed containers that belong to some service.
|
||||
opts := container.ListOptions{
|
||||
All: true,
|
||||
Filters: filters.NewArgs(
|
||||
filters.Arg("label", api.LabelServiceID),
|
||||
filters.Arg("label", api.LabelManaged),
|
||||
),
|
||||
}
|
||||
machineContainers, err := cli.Docker.ListContainers(listCtx, opts)
|
||||
if err != nil {
|
||||
return svc, fmt.Errorf("list containers: %w", err)
|
||||
}
|
||||
|
||||
// Collect all containers on all machines that belong to the specified service.
|
||||
foundByID := false
|
||||
var containers []api.MachineContainer
|
||||
for _, mc := range machineContainers {
|
||||
// Metadata can be nil if the request was broadcasted to only one machine.
|
||||
if mc.Metadata == nil && len(machineContainers) > 1 {
|
||||
return svc, errors.New("something went wrong with gRPC proxy: metadata is missing for a machine response")
|
||||
}
|
||||
if mc.Metadata != nil && mc.Metadata.Error != "" {
|
||||
// TODO: return failed machines in the response.
|
||||
fmt.Printf("WARNING: failed to list containers on machine '%s': %s\n",
|
||||
mc.Metadata.Machine, mc.Metadata.Error)
|
||||
continue
|
||||
}
|
||||
|
||||
machineID := ""
|
||||
if mc.Metadata == nil {
|
||||
// ListContainers was proxied to only one machine.
|
||||
for _, v := range machineIDByManagementIP {
|
||||
machineID = v
|
||||
break
|
||||
}
|
||||
} else {
|
||||
var ok bool
|
||||
machineID, ok = machineIDByManagementIP[mc.Metadata.Machine]
|
||||
if !ok {
|
||||
return svc, fmt.Errorf("machine name not found for management IP: %s", mc.Metadata.Machine)
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range mc.Containers {
|
||||
ctr := api.Container{ContainerJSON: c}
|
||||
if ctr.ServiceID() == id || ctr.ServiceName() == id {
|
||||
containers = append(containers, api.MachineContainer{
|
||||
MachineID: machineID,
|
||||
Container: ctr,
|
||||
})
|
||||
|
||||
if ctr.ServiceID() == id {
|
||||
foundByID = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(containers) == 0 {
|
||||
return svc, ErrNotFound
|
||||
}
|
||||
|
||||
// Containers from different services may share the same service name (distributed and eventually consistent store
|
||||
// may not prevent this), or a service name might match another service's ID. In these cases, matching by ID takes
|
||||
// priority over matching by name.
|
||||
if foundByID {
|
||||
containers = slices.DeleteFunc(containers, func(mc api.MachineContainer) bool {
|
||||
return mc.Container.ServiceID() != id
|
||||
})
|
||||
} else {
|
||||
// Matched only by name but there could be multiple services with the same name.
|
||||
serviceID := containers[0].Container.ServiceID()
|
||||
for _, mc := range containers[1:] {
|
||||
if mc.Container.ServiceID() != serviceID {
|
||||
return svc, fmt.Errorf("multiple services found with name '%s', use the service ID instead", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svc = api.Service{
|
||||
ID: containers[0].Container.ServiceID(),
|
||||
Name: containers[0].Container.ServiceName(),
|
||||
Mode: containers[0].Container.ServiceMode(),
|
||||
Containers: containers,
|
||||
}
|
||||
if svc.Mode == "" {
|
||||
svc.Mode = api.ServiceModeReplicated
|
||||
}
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// InspectServiceFromStore returns detailed information about a service and its containers from the distributed store.
|
||||
// Due to eventual consistency of the store, the returned information may not reflect the most recent changes.
|
||||
// The id parameter can be either a service ID or name.
|
||||
func (cli *Client) InspectServiceFromStore(ctx context.Context, id string) (api.Service, error) {
|
||||
var svc api.Service
|
||||
|
||||
resp, err := cli.MachineClient.InspectService(ctx, &pb.InspectServiceRequest{Id: id})
|
||||
if err != nil {
|
||||
if s, ok := status.FromError(err); ok {
|
||||
if s.Code() == codes.NotFound {
|
||||
return svc, ErrNotFound
|
||||
}
|
||||
}
|
||||
return svc, err
|
||||
}
|
||||
|
||||
svc, err = api.ServiceFromProto(resp.Service)
|
||||
if err != nil {
|
||||
return svc, fmt.Errorf("from proto: %w", err)
|
||||
}
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// RemoveService removes all containers on all machines that belong to the specified service.
|
||||
// The id parameter can be either a service ID or name.
|
||||
func (cli *Client) RemoveService(ctx context.Context, id string) error {
|
||||
svc, err := cli.InspectService(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
machines, err := cli.ListMachines(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
machineManagementIPByID := make(map[string]string)
|
||||
for _, m := range machines {
|
||||
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
|
||||
machineManagementIPByID[m.Machine.Id] = machineIP.String()
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
errCh := make(chan error)
|
||||
|
||||
// Remove all containers on all machines that belong to the service.
|
||||
for _, mc := range svc.Containers {
|
||||
wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
err := cli.StopContainer(ctx, svc.ID, mc.Container.ID, container.StopOptions{})
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("stop container '%s': %w", mc.Container.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = cli.RemoveContainer(ctx, svc.ID, mc.Container.ID, container.RemoveOptions{})
|
||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||
errCh <- fmt.Errorf("remove container '%s': %w", mc.Container.ID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
err = nil
|
||||
for e := range errCh {
|
||||
err = errors.Join(err, e)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ListServices returns a list of all services and their containers.
|
||||
func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) {
|
||||
machines, err := cli.ListMachines(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
|
||||
// Broadcast the container list request to all available machines.
|
||||
md := metadata.New(nil)
|
||||
for _, m := range machines {
|
||||
if m.State == pb.MachineMember_UP || m.State == pb.MachineMember_SUSPECT {
|
||||
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
|
||||
md.Append("machines", machineIP.String())
|
||||
}
|
||||
// TODO: warning about machines that are DOWN.
|
||||
}
|
||||
listCtx := metadata.NewOutgoingContext(ctx, md)
|
||||
|
||||
// List only uncloud-managed containers that belong to some service.
|
||||
opts := container.ListOptions{
|
||||
All: true,
|
||||
Filters: filters.NewArgs(
|
||||
filters.Arg("label", api.LabelServiceID),
|
||||
filters.Arg("label", api.LabelManaged),
|
||||
),
|
||||
}
|
||||
machineContainers, err := cli.Docker.ListContainers(listCtx, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list containers: %w", err)
|
||||
}
|
||||
|
||||
// TODO: optimise by extracting services from the list of all containers instead of inspecting each service.
|
||||
// Most of the code can be reused in both InspectService and ListServices.
|
||||
servicesByID := make(map[string]api.Service)
|
||||
for _, mc := range machineContainers {
|
||||
if mc.Metadata != nil && mc.Metadata.Error != "" {
|
||||
// TODO: return failed machines in the response.
|
||||
fmt.Printf("WARNING: failed to list containers on machine '%s': %s\n",
|
||||
mc.Metadata.Machine, mc.Metadata.Error)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, c := range mc.Containers {
|
||||
ctr := api.Container{ContainerJSON: c}
|
||||
if _, ok := servicesByID[ctr.ServiceID()]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
svc, err := cli.InspectService(ctx, ctr.ServiceID())
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("inspect service: %w", err)
|
||||
}
|
||||
|
||||
servicesByID[ctr.ServiceID()] = svc
|
||||
}
|
||||
}
|
||||
|
||||
services := make([]api.Service, 0, len(servicesByID))
|
||||
for _, svc := range servicesByID {
|
||||
services = append(services, svc)
|
||||
}
|
||||
return services, nil
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
)
|
||||
|
||||
// Strategy defines how a service should be deployed or updated. Different implementations can provide various
|
||||
// deployment patterns such as rolling updates, blue/green deployments, etc.
|
||||
type Strategy interface {
|
||||
// Type returns the type of the deployment strategy, e.g. "rolling", "blue-green".
|
||||
Type() string
|
||||
// Plan returns the operation to reconcile the service to the desired state.
|
||||
// If the service does not exist (new deployment), svc will be nil.
|
||||
Plan(ctx context.Context, cli *Client, svc *api.Service, spec api.ServiceSpec) (Plan, error)
|
||||
}
|
||||
|
||||
// RollingStrategy implements a rolling update deployment pattern where containers are updated one at a time
|
||||
// to minimize service disruption.
|
||||
type RollingStrategy struct {
|
||||
// MachineFilter optionally restricts which machines can be used for deployment.
|
||||
MachineFilter MachineFilter
|
||||
}
|
||||
|
||||
func (s *RollingStrategy) Type() string {
|
||||
return "rolling"
|
||||
}
|
||||
|
||||
func (s *RollingStrategy) Plan(
|
||||
ctx context.Context, cli *Client, svc *api.Service, spec api.ServiceSpec,
|
||||
) (Plan, error) {
|
||||
// We can assume that the spec is valid at this point because it has been validated by the deployment.
|
||||
switch spec.Mode {
|
||||
case api.ServiceModeReplicated:
|
||||
return s.planReplicated(ctx, cli, svc, spec)
|
||||
case api.ServiceModeGlobal:
|
||||
return s.planGlobal(ctx, cli, svc, spec)
|
||||
default:
|
||||
return Plan{}, fmt.Errorf("unsupported service mode: '%s'", spec.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// planReplicated creates a plan for a replicated service deployment.
|
||||
// For replicated services, we want to maintain a specific number of containers (replicas) across the available machines
|
||||
// in the cluster.
|
||||
func (s *RollingStrategy) planReplicated(
|
||||
ctx context.Context, cli *Client, svc *api.Service, spec api.ServiceSpec,
|
||||
) (Plan, error) {
|
||||
plan, err := newEmptyPlan(svc, spec)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
|
||||
machines, err := cli.ListMachines(ctx)
|
||||
if err != nil {
|
||||
return plan, fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
// Filter machines that are not DOWN and match the machine filter if provided.
|
||||
var availableMachines []*pb.MachineInfo
|
||||
var unmatchedMachines []*pb.MachineInfo
|
||||
var downMachines []*pb.MachineInfo
|
||||
for _, m := range machines {
|
||||
if m.State == pb.MachineMember_DOWN {
|
||||
downMachines = append(downMachines, m.Machine)
|
||||
} else {
|
||||
if s.MachineFilter == nil || s.MachineFilter(m.Machine) {
|
||||
availableMachines = append(availableMachines, m.Machine)
|
||||
} else {
|
||||
unmatchedMachines = append(unmatchedMachines, m.Machine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(availableMachines) == 0 {
|
||||
if s.MachineFilter != nil {
|
||||
return plan, ErrNoMatchingMachines
|
||||
}
|
||||
return plan, fmt.Errorf("no available machines to deploy service")
|
||||
}
|
||||
// Randomise the order of machines to avoid always deploying to the same machines first.
|
||||
rand.Shuffle(len(availableMachines), func(i, j int) {
|
||||
availableMachines[i], availableMachines[j] = availableMachines[j], availableMachines[i]
|
||||
})
|
||||
|
||||
// Organise existing containers by machine.
|
||||
containersOnMachine := make(map[string][]api.Container)
|
||||
upToDateContainersOnMachine := make(map[string]int)
|
||||
containerSpecStatuses := make(map[string]ContainerSpecStatus)
|
||||
if svc != nil {
|
||||
for _, c := range svc.Containers {
|
||||
if !c.Container.State.Running || c.Container.State.Paused {
|
||||
// Skip containers that are not running.
|
||||
continue
|
||||
}
|
||||
|
||||
status, err := CompareContainerToSpec(c.Container, spec)
|
||||
if err != nil {
|
||||
return plan, fmt.Errorf("compare container to spec: %w", err)
|
||||
}
|
||||
containerSpecStatuses[c.Container.ID] = status
|
||||
|
||||
if status == ContainerUpToDate {
|
||||
upToDateContainersOnMachine[c.MachineID] += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Sort containers such that running containers with the desired spec are first.
|
||||
slices.SortFunc(svc.Containers, func(c1, c2 api.MachineContainer) int {
|
||||
if status, ok := containerSpecStatuses[c1.Container.ID]; ok && status == ContainerUpToDate {
|
||||
return -1
|
||||
}
|
||||
if status, ok := containerSpecStatuses[c2.Container.ID]; ok && status == ContainerUpToDate {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
for _, c := range svc.Containers {
|
||||
containersOnMachine[c.MachineID] = append(containersOnMachine[c.MachineID], c.Container)
|
||||
}
|
||||
|
||||
// Sort machines such that machines with the most up-to-date containers are first, followed by machines with
|
||||
// existing containers, and finally machines without containers.
|
||||
slices.SortFunc(availableMachines, func(m1, m2 *pb.MachineInfo) int {
|
||||
if upToDateContainersOnMachine[m1.Id] > 0 && upToDateContainersOnMachine[m2.Id] > 0 {
|
||||
return upToDateContainersOnMachine[m2.Id] - upToDateContainersOnMachine[m1.Id]
|
||||
}
|
||||
if upToDateContainersOnMachine[m1.Id] > 0 {
|
||||
return -1
|
||||
}
|
||||
if upToDateContainersOnMachine[m2.Id] > 0 {
|
||||
return 1
|
||||
}
|
||||
return len(containersOnMachine[m2.Id]) - len(containersOnMachine[m1.Id])
|
||||
})
|
||||
}
|
||||
|
||||
// Spread the containers across the available machines evenly using a simple round-robin approach, starting with
|
||||
// machines that already have containers and prioritising machines with containers that match the desired spec.
|
||||
for i := 0; i < int(spec.Replicas); i++ {
|
||||
m := availableMachines[i%len(availableMachines)]
|
||||
containers := containersOnMachine[m.Id]
|
||||
|
||||
if len(containers) == 0 {
|
||||
// No more existing containers on this machine, create a new one.
|
||||
plan.Operations = append(plan.Operations, &RunContainerOperation{
|
||||
ServiceID: plan.ServiceID,
|
||||
Spec: spec,
|
||||
MachineID: m.Id,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
ctr := containers[0]
|
||||
containersOnMachine[m.Id] = containers[1:]
|
||||
|
||||
if status, ok := containerSpecStatuses[ctr.ID]; ok { // Contains statuses for only running containers.
|
||||
if status == ContainerUpToDate {
|
||||
continue
|
||||
}
|
||||
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
|
||||
|
||||
conflictingPorts, portsErr := ctr.ConflictingServicePorts(spec.Ports)
|
||||
if portsErr != nil || len(conflictingPorts) > 0 {
|
||||
// Stop the malformed container or the container with conflicting ports.
|
||||
plan.Operations = append(plan.Operations, &StopContainerOperation{
|
||||
ServiceID: plan.ServiceID,
|
||||
ContainerID: ctr.ID,
|
||||
MachineID: m.Id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Run a new container.
|
||||
plan.Operations = append(plan.Operations, &RunContainerOperation{
|
||||
ServiceID: plan.ServiceID,
|
||||
Spec: spec,
|
||||
MachineID: m.Id,
|
||||
})
|
||||
|
||||
// Remove the old container.
|
||||
plan.Operations = append(plan.Operations, &RemoveContainerOperation{
|
||||
ServiceID: plan.ServiceID,
|
||||
ContainerID: ctr.ID,
|
||||
MachineID: m.Id,
|
||||
})
|
||||
}
|
||||
|
||||
// Remove any remaining containers that are not needed.
|
||||
for mid, containers := range containersOnMachine {
|
||||
for _, c := range containers {
|
||||
plan.Operations = append(plan.Operations, &RemoveContainerOperation{
|
||||
ServiceID: plan.ServiceID,
|
||||
ContainerID: c.ID,
|
||||
MachineID: mid,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// planGlobal creates a plan for a global service deployment, ensuring one container runs on each available machine.
|
||||
// For machines with an existing container, it attempts to start a new container before removing the old one if
|
||||
// possible. If the new container would have port conflicts with the existing one, the old container is removed first.
|
||||
// It handles multiple containers per machine (though this should not occur in normal operation) and skips machines
|
||||
// that are down.
|
||||
func (s *RollingStrategy) planGlobal(
|
||||
ctx context.Context, cli *Client, svc *api.Service, spec api.ServiceSpec,
|
||||
) (Plan, error) {
|
||||
plan, err := newEmptyPlan(svc, spec)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
|
||||
// Map machineID to service containers on that machine. For the global mode, there should be at most one
|
||||
// container per machine but we use a slice to handle multiple containers that may exist due to a bug
|
||||
// or interruption in the previous deployment.
|
||||
containersOnMachine := make(map[string][]api.MachineContainer)
|
||||
if svc != nil {
|
||||
for _, c := range svc.Containers {
|
||||
containersOnMachine[c.MachineID] = append(containersOnMachine[c.MachineID], c)
|
||||
}
|
||||
}
|
||||
|
||||
machines, err := cli.ListMachines(ctx)
|
||||
if err != nil {
|
||||
return plan, fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
// Filter machines if a machine filter is provided.
|
||||
// TODO: not sure this is the right behaviour to ignore other machines that might run service containers.
|
||||
// Maybe there should be another filter to specify which machines to deploy to but keep the rest running.
|
||||
// Could be useful to test a new version on a subset of machines before rolling out to all.
|
||||
if s.MachineFilter != nil {
|
||||
machines = slices.DeleteFunc(machines, func(m *pb.MachineMember) bool {
|
||||
return !s.MachineFilter(m.Machine)
|
||||
})
|
||||
if len(machines) == 0 {
|
||||
return plan, ErrNoMatchingMachines
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: figure out how to return a warning if there are machines down. Embed the machinesDown in the plan?
|
||||
var machinesDown []*pb.MachineInfo
|
||||
for _, m := range machines {
|
||||
// Skip machines that are down but collect them to report a warning later.
|
||||
if m.State == pb.MachineMember_DOWN {
|
||||
machinesDown = append(machinesDown, m.Machine)
|
||||
fmt.Printf("WARNING: failed to run a service container on machine '%s' which is Down.\n", m.Machine.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
containers := containersOnMachine[m.Machine.Id]
|
||||
ops, err := reconcileGlobalContainer(containers, spec, plan.ServiceID, m.Machine.Id)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
plan.Operations = append(plan.Operations, ops...)
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// reconcileGlobalContainer returns a sequence of operations to reconcile containers on a machine for a global service.
|
||||
// It ensures exactly one container with the desired spec is running on the machine by creating a new container and
|
||||
// removing old ones. If there is a host port conflict, it stops the old container before starting a new one.
|
||||
func reconcileGlobalContainer(
|
||||
containers []api.MachineContainer, spec api.ServiceSpec, serviceID, machineID string,
|
||||
) ([]Operation, error) {
|
||||
var ops []Operation
|
||||
|
||||
if len(containers) == 0 {
|
||||
// No containers on this machine, create a new one.
|
||||
ops = append(ops, &RunContainerOperation{
|
||||
ServiceID: serviceID,
|
||||
Spec: spec,
|
||||
MachineID: machineID,
|
||||
})
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// Check if there is a container with the same spec already running. If so, remove the rest.
|
||||
upToDate := false
|
||||
for i, c := range containers {
|
||||
if !c.Container.State.Running || c.Container.State.Paused {
|
||||
// Skip containers that are not running.
|
||||
continue
|
||||
}
|
||||
|
||||
status, err := CompareContainerToSpec(c.Container, spec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compare container to spec: %w", err)
|
||||
}
|
||||
if status == ContainerUpToDate {
|
||||
// The container is already running with the same spec.
|
||||
upToDate = true
|
||||
for j, old := range containers {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
ops = append(ops, &RemoveContainerOperation{
|
||||
ServiceID: serviceID,
|
||||
ContainerID: old.Container.ID,
|
||||
MachineID: old.MachineID,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
|
||||
}
|
||||
if upToDate {
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// The machine has containers but none of them match the new spec.
|
||||
// Stop the old running containers that have conflicting ports with the new spec before running a new one.
|
||||
for _, c := range containers {
|
||||
if c.Container.State.Running {
|
||||
conflictingPorts, err := c.Container.ConflictingServicePorts(spec.Ports)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check conflicting ports: %w", err)
|
||||
}
|
||||
|
||||
if len(conflictingPorts) > 0 {
|
||||
// Stop the running container with conflicting ports.
|
||||
ops = append(ops, &StopContainerOperation{
|
||||
ServiceID: serviceID,
|
||||
ContainerID: c.Container.ID,
|
||||
MachineID: c.MachineID,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run a new container.
|
||||
ops = append(ops, &RunContainerOperation{
|
||||
ServiceID: serviceID,
|
||||
Spec: spec,
|
||||
MachineID: machineID,
|
||||
})
|
||||
|
||||
// Remove the old containers.
|
||||
for _, c := range containers {
|
||||
ops = append(ops, &RemoveContainerOperation{
|
||||
ServiceID: serviceID,
|
||||
ContainerID: c.Container.ID,
|
||||
MachineID: c.MachineID,
|
||||
})
|
||||
}
|
||||
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// newEmptyPlan creates a new empty plan for a service deployment with initialised service ID and name.
|
||||
func newEmptyPlan(svc *api.Service, spec api.ServiceSpec) (Plan, error) {
|
||||
var plan Plan
|
||||
|
||||
// Generate a new service ID for the initial service deployment if it doesn't exist yet.
|
||||
if svc != nil {
|
||||
plan.ServiceID = svc.ID
|
||||
plan.ServiceName = svc.Name
|
||||
} else {
|
||||
var err error
|
||||
plan.ServiceID, err = secret.NewID()
|
||||
if err != nil {
|
||||
return plan, fmt.Errorf("generate service ID: %w", err)
|
||||
}
|
||||
plan.ServiceName = spec.Name
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"net/netip"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
privateKey wgtypes.Key
|
||||
}
|
||||
|
||||
func NewUser(privateKey secret.Secret) (*User, error) {
|
||||
var (
|
||||
wgKey wgtypes.Key
|
||||
err error
|
||||
)
|
||||
if privateKey == nil {
|
||||
wgKey, err = wgtypes.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate key for user: %w", err)
|
||||
}
|
||||
privateKey = wgKey[:]
|
||||
} else {
|
||||
wgKey, err = wgtypes.NewKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid key: %w", err)
|
||||
}
|
||||
privateKey = wgKey[:]
|
||||
}
|
||||
return &User{
|
||||
privateKey: wgKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *User) PrivateKey() secret.Secret {
|
||||
return u.privateKey[:]
|
||||
}
|
||||
|
||||
func (u *User) PublicKey() secret.Secret {
|
||||
pubKey := u.privateKey.PublicKey()
|
||||
return pubKey[:]
|
||||
}
|
||||
|
||||
func (u *User) ManagementIP() netip.Addr {
|
||||
return network.ManagementIP(u.PublicKey())
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
)
|
||||
|
||||
const PortsExtensionKey = "x-ports"
|
||||
|
||||
type PortsSource []string
|
||||
|
||||
// TransformServicesPortsExtension transforms the ports extension of all services in the project by replacing a string
|
||||
// representation of each port with a parsed PortSpec.
|
||||
func transformServicesPortsExtension(project *types.Project) (*types.Project, error) {
|
||||
return project.WithServicesTransform(func(name string, service types.ServiceConfig) (types.ServiceConfig, error) {
|
||||
ports, ok := service.Extensions[PortsExtensionKey].(PortsSource)
|
||||
if !ok {
|
||||
return service, nil
|
||||
}
|
||||
|
||||
specs, err := transformPortsExtension(ports)
|
||||
if err != nil {
|
||||
return service, err
|
||||
}
|
||||
|
||||
service.Extensions[PortsExtensionKey] = specs
|
||||
return service, nil
|
||||
})
|
||||
}
|
||||
|
||||
func transformPortsExtension(ports PortsSource) ([]api.PortSpec, error) {
|
||||
var specs []api.PortSpec
|
||||
for _, port := range ports {
|
||||
spec, err := api.ParsePortSpec(port)
|
||||
if err != nil {
|
||||
return specs, fmt.Errorf("parse port %q: %w", port, err)
|
||||
}
|
||||
specs = append(specs, spec)
|
||||
}
|
||||
|
||||
return specs, nil
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package compose
|
||||
|
||||
// TODO: make compose, cli, and api packages public.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
)
|
||||
|
||||
func LoadProject(ctx context.Context, paths []string) (*types.Project, error) {
|
||||
options, err := composecli.NewProjectOptions(
|
||||
paths,
|
||||
// First apply os.Environment, always wins.
|
||||
composecli.WithOsEnv,
|
||||
// Read dot env file to populate project environment.
|
||||
composecli.WithDotEnv,
|
||||
// Get compose file path set by COMPOSE_FILE.
|
||||
composecli.WithConfigFileEnv,
|
||||
// If none was selected, get default compose.yaml file from current dir or parent folders.
|
||||
composecli.WithDefaultConfigPath,
|
||||
composecli.WithExtension(PortsExtensionKey, PortsSource{}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create compose parser options: %w", err)
|
||||
}
|
||||
|
||||
project, err := options.LoadProject(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if project, err = transformServicesPortsExtension(project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
)
|
||||
|
||||
func ServiceSpecFromCompose(name string, service types.ServiceConfig) (api.ServiceSpec, error) {
|
||||
spec := api.ServiceSpec{
|
||||
Container: api.ContainerSpec{
|
||||
Command: service.Command,
|
||||
Image: service.Image,
|
||||
Init: service.Init,
|
||||
// TODO: env
|
||||
// TODO: volumes
|
||||
},
|
||||
Name: name,
|
||||
}
|
||||
|
||||
if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok {
|
||||
spec.Ports = ports
|
||||
}
|
||||
|
||||
if service.Deploy != nil {
|
||||
switch service.Deploy.Mode {
|
||||
case "global":
|
||||
spec.Mode = api.ServiceModeGlobal
|
||||
case "", "replicated":
|
||||
spec.Mode = api.ServiceModeReplicated
|
||||
if service.Deploy.Replicas != nil {
|
||||
spec.Replicas = uint(*service.Deploy.Replicas)
|
||||
}
|
||||
default:
|
||||
return spec, fmt.Errorf("unsupported deploy mode: %s", service.Deploy.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
@@ -8,14 +8,14 @@ import (
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy"
|
||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||
)
|
||||
|
||||
func GenerateConfig(containers []api.Container, verifyResponse string) (*caddy.Config, error) {
|
||||
|
||||
@@ -4,12 +4,12 @@ import (
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"strings"
|
||||
"testing"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||
)
|
||||
|
||||
func TestGenerateConfig(t *testing.T) {
|
||||
|
||||
@@ -4,12 +4,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/psviderski/uncloud/internal/fs"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
"github.com/psviderski/uncloud/internal/fs"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"github.com/docker/docker/api/types/events"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"log/slog"
|
||||
"time"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
"github.com/psviderski/uncloud/internal/machine/store"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
"github.com/psviderski/uncloud/internal/api"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
"github.com/docker/docker/api/types/image"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/psviderski/uncloud/pkg/client/connector"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
"github.com/psviderski/uncloud/internal/cli/client"
|
||||
"github.com/psviderski/uncloud/internal/cli/client/connector"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
Reference in New Issue
Block a user