refactor: decouple deploy and compose packages from client dependency

This commit is contained in:
Pavel Sviderski
2025-03-24 14:38:16 +10:00
parent 1471a94162
commit 042dd594e0
27 changed files with 509 additions and 459 deletions
+37
View File
@@ -0,0 +1,37 @@
package api
import (
"context"
"github.com/docker/docker/api/types/container"
"github.com/psviderski/uncloud/internal/machine/api/pb"
)
type Client interface {
ContainerClient
DNSClient
MachineClient
ServiceClient
}
type ContainerClient interface {
CreateContainer(
ctx context.Context, serviceID string, spec ServiceSpec, machineID string,
) (container.CreateResponse, error)
InspectContainer(ctx context.Context, serviceID, containerID string) (MachineContainer, error)
RemoveContainer(ctx context.Context, serviceID, containerID string, opts container.RemoveOptions) error
StartContainer(ctx context.Context, serviceID, containerID string) error
StopContainer(ctx context.Context, serviceID, containerID string, opts container.StopOptions) error
}
type DNSClient interface {
GetDomain(ctx context.Context) (string, error)
}
type MachineClient interface {
InspectMachine(ctx context.Context, id string) (*pb.MachineMember, error)
ListMachines(ctx context.Context) ([]*pb.MachineMember, error)
}
type ServiceClient interface {
InspectService(ctx context.Context, id string) (Service, error)
}
+5
View File
@@ -0,0 +1,5 @@
package api
import "errors"
var ErrNotFound = errors.New("not found")
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/deploy"
"regexp"
)
@@ -21,7 +22,7 @@ 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) {
func (cli *Client) NewCaddyDeployment(image string, filter deploy.MachineFilter) (*deploy.Deployment, error) {
latest, err := latestCaddyImage()
if err != nil {
return nil, fmt.Errorf("look up latest Caddy image: %w", err)
@@ -55,7 +56,7 @@ func (cli *Client) NewCaddyDeployment(image string, filter MachineFilter) (*Depl
},
}
return cli.NewDeployment(spec, &RollingStrategy{MachineFilter: filter})
return cli.NewDeployment(spec, &deploy.RollingStrategy{MachineFilter: filter}), nil
}
// latestCaddyImage returns the latest image of the official Caddy Docker image on Docker Hub.
+3 -2
View File
@@ -7,13 +7,12 @@ import (
"github.com/docker/cli/cli/streams"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/docker"
"github.com/psviderski/uncloud/pkg/api"
"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
@@ -26,6 +25,8 @@ type Client struct {
Docker *docker.Client
}
var _ api.Client = (*Client)(nil)
// Connector is an interface for establishing a connection to the machine API.
type Connector interface {
Connect(ctx context.Context) (*grpc.ClientConn, error)
+2 -1
View File
@@ -3,6 +3,7 @@ package client
import (
"context"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api"
"google.golang.org/protobuf/types/known/emptypb"
)
@@ -18,7 +19,7 @@ func (cli *Client) InspectMachine(ctx context.Context, id string) (*pb.MachineMe
}
}
return nil, ErrNotFound
return nil, api.ErrNotFound
}
func (cli *Client) ListMachines(ctx context.Context) ([]*pb.MachineMember, error) {
+1 -24
View File
@@ -256,7 +256,7 @@ func (cli *Client) InspectContainer(ctx context.Context, serviceID, containerID
}
}
if ctr.MachineID == "" {
return ctr, ErrNotFound
return ctr, api.ErrNotFound
}
return ctr, nil
@@ -340,26 +340,3 @@ func (cli *Client) RemoveContainer(
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
}
+4 -107
View File
@@ -1,115 +1,12 @@
package client
import (
"context"
"errors"
"fmt"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/deploy"
)
// 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)
// If strategy is nil, a default deploy.RollingStrategy will be used.
func (cli *Client) NewDeployment(spec api.ServiceSpec, strategy deploy.Strategy) *deploy.Deployment {
return deploy.NewDeployment(cli, spec, strategy)
}
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/caddyfile"
"github.com/psviderski/uncloud/pkg/api"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"io"
@@ -21,7 +22,7 @@ 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 "", api.ErrNotFound
}
return "", err
}
+30 -110
View File
@@ -1,133 +1,53 @@
package client
import (
"context"
"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)
// MapNameResolver resolves machine and container IDs to their names using a static map.
type MapNameResolver struct {
machines map[string]string
containers map[string]string
}
// ServiceSpecResolver transforms user-provided service specs into deployment-ready form.
type ServiceSpecResolver struct {
ClusterDomain string
ImageResolver ImageDigestResolver
func NewNameResolver(machines, containers map[string]string) *MapNameResolver {
return &MapNameResolver{
machines: machines,
containers: containers,
}
}
// 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)
func (r *MapNameResolver) MachineName(machineID string) string {
if name, ok := r.machines[machineID]; ok {
return name
}
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
return machineID
}
func (r *ServiceSpecResolver) applyDefaults(spec *api.ServiceSpec) error {
if spec.Mode == "" {
spec.Mode = api.ServiceModeReplicated
func (r *MapNameResolver) ContainerName(containerID string) string {
if name, ok := r.containers[containerID]; ok {
return name
}
// Ensure the replicated service has at least one replica.
if spec.Mode == api.ServiceModeReplicated && spec.Replicas == 0 {
spec.Replicas = 1
}
return nil
return containerID
}
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)
// 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 fmt.Errorf("invalid image: %w", err)
return nil, fmt.Errorf("list machines: %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:]
machineNames := make(map[string]string, len(machines))
for _, m := range machines {
machineNames[m.Machine.Id] = m.Machine.Name
}
// 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)
containerNames := make(map[string]string, len(svc.Containers))
for _, c := range svc.Containers {
containerNames[c.Container.ID] = c.Container.NameWithoutSlash()
}
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
return NewNameResolver(machineNames, containerNames), nil
}
+11 -13
View File
@@ -9,6 +9,7 @@ import (
"github.com/docker/docker/api/types/filters"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/deploy"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
@@ -18,11 +19,11 @@ import (
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) {
if err != nil && !errors.Is(err, api.ErrNotFound) {
return spec, fmt.Errorf("get cluster domain: %w", err)
}
resolver := ServiceSpecResolver{
resolver := deploy.ServiceSpecResolver{
// If the domain is not found (not reserved), an empty domain is used for the resolver.
ClusterDomain: domain,
// TODO: provide an image resolver.
@@ -41,7 +42,7 @@ type RunServiceResponse struct {
}
func (cli *Client) RunService(
ctx context.Context, spec api.ServiceSpec, filter MachineFilter,
ctx context.Context, spec api.ServiceSpec, filter deploy.MachineFilter,
) (RunServiceResponse, error) {
var resp RunServiceResponse
@@ -55,7 +56,7 @@ func (cli *Client) RunService(
if err == nil {
return resp, fmt.Errorf("service with name '%s' already exists", spec.Name)
}
if !errors.Is(err, ErrNotFound) {
if !errors.Is(err, api.ErrNotFound) {
return resp, fmt.Errorf("inspect service: %w", err)
}
}
@@ -66,12 +67,9 @@ func (cli *Client) RunService(
}
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)
}
deployment := cli.NewDeployment(spec, &deploy.RollingStrategy{MachineFilter: filter})
plan, err := deploy.Run(ctx)
plan, err := deployment.Run(ctx)
if err != nil {
return err
}
@@ -168,7 +166,7 @@ func (cli *Client) InspectService(ctx context.Context, id string) (api.Service,
}
if len(containers) == 0 {
return svc, ErrNotFound
return svc, api.ErrNotFound
}
// Containers from different services may share the same service name (distributed and eventually consistent store
@@ -211,7 +209,7 @@ func (cli *Client) InspectServiceFromStore(ctx context.Context, id string) (api.
if err != nil {
if s, ok := status.FromError(err); ok {
if s.Code() == codes.NotFound {
return svc, ErrNotFound
return svc, api.ErrNotFound
}
}
return svc, err
@@ -259,7 +257,7 @@ func (cli *Client) RemoveService(ctx context.Context, id string) error {
}
err = cli.RemoveContainer(ctx, svc.ID, mc.Container.ID, container.RemoveOptions{})
if err != nil && !errors.Is(err, ErrNotFound) {
if err != nil && !errors.Is(err, api.ErrNotFound) {
errCh <- fmt.Errorf("remove container '%s': %w", mc.Container.ID, err)
}
}()
@@ -327,7 +325,7 @@ func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) {
svc, err := cli.InspectService(ctx, ctr.ServiceID())
if err != nil {
if errors.Is(err, ErrNotFound) {
if errors.Is(err, api.ErrNotFound) {
continue
}
return nil, fmt.Errorf("inspect service: %w", err)
+21 -19
View File
@@ -7,16 +7,28 @@ import (
"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/client"
"github.com/psviderski/uncloud/pkg/deploy"
)
func NewDeployment(ctx context.Context, cli *client.Client, project *types.Project) (*Deployment, error) {
type Client interface {
api.DNSClient
deploy.Client
}
type Deployment struct {
Client Client
Project *types.Project
SpecResolver *deploy.ServiceSpecResolver
plan *deploy.SequenceOperation
}
func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*Deployment, error) {
domain, err := cli.GetDomain(ctx)
if err != nil && !errors.Is(err, client.ErrNotFound) {
if err != nil && !errors.Is(err, api.ErrNotFound) {
return nil, fmt.Errorf("get cluster domain: %w", err)
}
resolver := &client.ServiceSpecResolver{
resolver := &deploy.ServiceSpecResolver{
// If the domain is not found (not reserved), an empty domain is used for the resolver.
ClusterDomain: domain,
// TODO: provide an image resolver.
@@ -29,19 +41,12 @@ func NewDeployment(ctx context.Context, cli *client.Client, project *types.Proje
}, nil
}
type Deployment struct {
Client *client.Client
Project *types.Project
SpecResolver *client.ServiceSpecResolver
plan *client.SequenceOperation
}
func (d *Deployment) Plan(ctx context.Context) (client.SequenceOperation, error) {
func (d *Deployment) Plan(ctx context.Context) (deploy.SequenceOperation, error) {
if d.plan != nil {
return *d.plan, nil
}
plan := client.SequenceOperation{}
plan := deploy.SequenceOperation{}
err := graph.InDependencyOrder(ctx, d.Project,
func(ctx context.Context, name string, _ types.ServiceConfig) error {
spec, err := d.ServiceSpec(name)
@@ -49,13 +54,10 @@ func (d *Deployment) Plan(ctx context.Context) (client.SequenceOperation, error)
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)
}
// TODO: properly handle depends_on conditions in the service deployment plan as the first operation.
deployment := deploy.NewDeployment(d.Client, spec, nil)
servicePlan, err := deploy.Plan(ctx)
servicePlan, err := deployment.Plan(ctx)
if err != nil {
return fmt.Errorf("create deployment plan for service '%s': %w", name, err)
}
+29
View File
@@ -0,0 +1,29 @@
package deploy
import (
"fmt"
"github.com/psviderski/uncloud/pkg/api"
)
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
}
+120
View File
@@ -0,0 +1,120 @@
package deploy
import (
"context"
"errors"
"fmt"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api"
)
type Client interface {
api.ContainerClient
api.MachineClient
api.ServiceClient
}
// 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.
func NewDeployment(cli Client, spec api.ServiceSpec, strategy Strategy) *Deployment {
if strategy == nil {
strategy = &RollingStrategy{}
}
return &Deployment{
Spec: spec,
Strategy: strategy,
cli: cli,
}
}
// 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, api.ErrNotFound) {
return fmt.Errorf("inspect service: %w", err)
}
}
// d.Service is nil if the service doesn't exist yet (first deployment).
if d.Service == nil {
return nil
}
if d.Service.Name != d.Spec.Name {
return errors.New("service name cannot be changed")
}
if d.Service.Mode != d.Spec.Mode {
return errors.New("service mode cannot be changed")
}
if d.Spec.Mode == api.ServiceModeReplicated && d.Spec.Replicas < 1 {
return errors.New("number of replicas must be at least 1")
}
return nil
}
// Run executes the deployment plan and returns the ID of the created or updated service.
// It will create a new plan if one hasn't been created yet. The deployment will either create a new service or update
// the existing one to match the desired specification.
// TODO: forbid to run the same deployment more than once.
func (d *Deployment) Run(ctx context.Context) (Plan, error) {
plan, err := d.Plan(ctx)
if err != nil {
return plan, fmt.Errorf("create plan: %w", err)
}
return plan, plan.Execute(ctx, d.cli)
}
@@ -1,4 +1,4 @@
package client
package deploy
import (
"context"
@@ -14,7 +14,7 @@ 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
Execute(ctx context.Context, cli Client) error
// Format returns a human-readable representation of the operation.
Format(resolver NameResolver) string
String() string
@@ -33,7 +33,7 @@ type RunContainerOperation struct {
MachineID string
}
func (o *RunContainerOperation) Execute(ctx context.Context, cli *Client) error {
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)
@@ -64,7 +64,7 @@ type StopContainerOperation struct {
MachineID string
}
func (o *StopContainerOperation) Execute(ctx context.Context, cli *Client) error {
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)
}
@@ -88,7 +88,7 @@ type RemoveContainerOperation struct {
MachineID string
}
func (o *RemoveContainerOperation) Execute(ctx context.Context, cli *Client) error {
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)
}
@@ -114,7 +114,7 @@ type SequenceOperation struct {
Operations []Operation
}
func (o *SequenceOperation) Execute(ctx context.Context, cli *Client) error {
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
@@ -140,49 +140,3 @@ func (o *SequenceOperation) String() 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
@@ -0,0 +1,133 @@
package deploy
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
}
@@ -1,4 +1,4 @@
package client
package deploy
import (
"context"
@@ -17,7 +17,7 @@ type Strategy interface {
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)
Plan(ctx context.Context, cli api.MachineClient, svc *api.Service, spec api.ServiceSpec) (Plan, error)
}
// RollingStrategy implements a rolling update deployment pattern where containers are updated one at a time
@@ -32,7 +32,7 @@ func (s *RollingStrategy) Type() string {
}
func (s *RollingStrategy) Plan(
ctx context.Context, cli *Client, svc *api.Service, spec api.ServiceSpec,
ctx context.Context, cli api.MachineClient, 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 {
@@ -49,7 +49,7 @@ func (s *RollingStrategy) Plan(
// 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,
ctx context.Context, cli api.MachineClient, svc *api.Service, spec api.ServiceSpec,
) (Plan, error) {
plan, err := newEmptyPlan(svc, spec)
if err != nil {
@@ -211,7 +211,7 @@ func (s *RollingStrategy) planReplicated(
// 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,
ctx context.Context, cli api.MachineClient, svc *api.Service, spec api.ServiceSpec,
) (Plan, error) {
plan, err := newEmptyPlan(svc, spec)
if err != nil {