refactor: move api, client, compose packages to pkg

This commit is contained in:
Pavel Sviderski
2025-03-22 18:43:00 +10:00
parent 3757813b20
commit b1abd07dde
42 changed files with 82 additions and 82 deletions
+4 -4
View File
@@ -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"
-101
View File
@@ -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
}
-69
View File
@@ -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)
})
}
-67
View File
@@ -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)
}
-30
View File
@@ -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
}
-110
View File
@@ -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)
}
-88
View File
@@ -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
}
-33
View File
@@ -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
}
-365
View File
@@ -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
}
-115
View File
@@ -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)
}
-181
View File
@@ -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)",
)
}
-188
View File
@@ -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
}
-133
View File
@@ -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
}
-345
View File
@@ -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
}
-377
View File
@@ -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
}
-49
View File
@@ -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())
}