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:
@@ -0,0 +1,101 @@
|
||||
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/pkg/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
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/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)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
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"
|
||||
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"google.golang.org/grpc/status"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/caddyfile"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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)",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
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"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
"slices"
|
||||
"sync"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/internal/secret"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user