diff --git a/cmd/uncloud/caddy/deploy.go b/cmd/uncloud/caddy/deploy.go index 9ca40635..0b9ce184 100644 --- a/cmd/uncloud/caddy/deploy.go +++ b/cmd/uncloud/caddy/deploy.go @@ -96,7 +96,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { filter = machineFilter(machines) } - d, err := clusterClient.NewCaddyDeployment(opts.image, filter) + d, err := clusterClient.NewCaddyDeployment(ctx, opts.image, filter) if err != nil { return fmt.Errorf("create caddy deployment: %w", err) } diff --git a/cmd/uncloud/deploy.go b/cmd/uncloud/deploy.go index 1f2c42b0..e5e212da 100644 --- a/cmd/uncloud/deploy.go +++ b/cmd/uncloud/deploy.go @@ -40,7 +40,7 @@ func NewDeployCommand() *cobra.Command { } cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil, - "One or more Compose files to deploy services from. (default compose.yaml)") + "One or more Compose files to deploy services from. (default compose-ports-long.yaml)") cmd.Flags().StringVarP(&opts.cluster, "cluster", "c", "", "Name of the cluster to deploy to (default is the current cluster)") diff --git a/cmd/uncloud/machine/add.go b/cmd/uncloud/machine/add.go index 4941269c..351c81fc 100644 --- a/cmd/uncloud/machine/add.go +++ b/cmd/uncloud/machine/add.go @@ -136,7 +136,7 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, o } } - d, err := machineClient.NewCaddyDeployment(caddyImage, filter) + d, err := machineClient.NewCaddyDeployment(ctx, caddyImage, filter) if err != nil { return fmt.Errorf("create caddy deployment: %w", err) } diff --git a/cmd/uncloud/machine/init.go b/cmd/uncloud/machine/init.go index 5a0cc355..c3ae9f81 100644 --- a/cmd/uncloud/machine/init.go +++ b/cmd/uncloud/machine/init.go @@ -4,14 +4,14 @@ import ( "context" "fmt" "github.com/docker/compose/v2/pkg/progress" - "github.com/spf13/cobra" - "net/netip" "github.com/psviderski/uncloud/cmd/uncloud/caddy" "github.com/psviderski/uncloud/cmd/uncloud/dns" "github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli/config" "github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/internal/machine/cluster" + "github.com/spf13/cobra" + "net/netip" ) type initOptions struct { @@ -132,7 +132,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM } if !opts.noCaddy { - d, err := client.NewCaddyDeployment("", nil) + d, err := client.NewCaddyDeployment(ctx, "", nil) if err != nil { return fmt.Errorf("create caddy deployment: %w", err) } diff --git a/cmd/uncloud/service/scale.go b/cmd/uncloud/service/scale.go index b04020c9..0fcb512d 100644 --- a/cmd/uncloud/service/scale.go +++ b/cmd/uncloud/service/scale.go @@ -89,7 +89,11 @@ func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error { spec.Replicas = opts.replicas - deployment := clusterClient.NewDeployment(spec, nil) + deployment, err := clusterClient.NewDeployment(ctx, spec, nil) + if err != nil { + return fmt.Errorf("create deployment: %w", err) + } + plan, err := deployment.Plan(ctx) if err != nil { return fmt.Errorf("plan deployment: %w", err) diff --git a/pkg/api/service.go b/pkg/api/service.go index b70b1aea..5b990798 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -120,6 +120,18 @@ func (s *ServiceSpec) Equals(spec ServiceSpec) bool { return reflect.DeepEqual(*s, spec) } +func (s *ServiceSpec) Clone() ServiceSpec { + spec := *s + + if s.Ports != nil { + spec.Ports = make([]PortSpec, len(s.Ports)) + copy(spec.Ports, s.Ports) + } + spec.Container = s.Container.Clone() + + return spec +} + type ContainerSpec struct { // Command overrides the default CMD of the image to be executed when running a container. Command []string @@ -140,6 +152,25 @@ func (s *ContainerSpec) Validate() error { return nil } +func (s *ContainerSpec) Clone() ContainerSpec { + spec := *s + + if s.Command != nil { + spec.Command = make([]string, len(s.Command)) + copy(spec.Command, s.Command) + } + if s.Entrypoint != nil { + spec.Entrypoint = make([]string, len(s.Entrypoint)) + copy(spec.Entrypoint, s.Entrypoint) + } + if s.Volumes != nil { + spec.Volumes = make([]string, len(s.Volumes)) + copy(spec.Volumes, s.Volumes) + } + + return spec +} + type Service struct { ID string Name string diff --git a/pkg/client/caddy.go b/pkg/client/caddy.go index 67ca31ac..07c66078 100644 --- a/pkg/client/caddy.go +++ b/pkg/client/caddy.go @@ -1,6 +1,7 @@ package client import ( + "context" "fmt" "github.com/Masterminds/semver" "github.com/distribution/reference" @@ -22,8 +23,10 @@ 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 deploy.MachineFilter) (*deploy.Deployment, error) { - latest, err := latestCaddyImage() +func (cli *Client) NewCaddyDeployment( + ctx context.Context, 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) } @@ -56,12 +59,12 @@ func (cli *Client) NewCaddyDeployment(image string, filter deploy.MachineFilter) }, } - return cli.NewDeployment(spec, &deploy.RollingStrategy{MachineFilter: filter}), nil + return cli.NewDeployment(ctx, spec, &deploy.RollingStrategy{MachineFilter: filter}) } -// latestCaddyImage returns the latest image of the official Caddy Docker image on Docker Hub. +// 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) { +func LatestCaddyImage() (reference.NamedTagged, error) { repo, err := name.NewRepository(CaddyImage) if err != nil { return nil, fmt.Errorf("parse image: %w", err) diff --git a/pkg/client/caddy_test.go b/pkg/client/caddy_test.go index 489d51c3..9cfe4abf 100644 --- a/pkg/client/caddy_test.go +++ b/pkg/client/caddy_test.go @@ -1,69 +1,17 @@ package client import ( - "github.com/psviderski/uncloud/pkg/api" + "github.com/distribution/reference" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "testing" ) -func TestClient_NewCaddyDeployment(t *testing.T) { +func TestLatestCaddyImage(t *testing.T) { t.Parallel() - cli := &Client{} + image, err := LatestCaddyImage() + require.NoError(t, err) - 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) - }) + assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, reference.FamiliarString(image)) } diff --git a/pkg/client/compose/deploy.go b/pkg/client/compose/deploy.go index d0d69dfa..c73236b0 100644 --- a/pkg/client/compose/deploy.go +++ b/pkg/client/compose/deploy.go @@ -55,7 +55,10 @@ func (d *Deployment) Plan(ctx context.Context) (deploy.SequenceOperation, error) } // TODO: properly handle depends_on conditions in the service deployment plan as the first operation. - deployment := deploy.NewDeployment(d.Client, spec, nil) + deployment, err := deploy.NewDeployment(ctx, d.Client, spec, nil) + if err != nil { + return fmt.Errorf("create deployment for service '%s': %w", name, err) + } servicePlan, err := deployment.Plan(ctx) if err != nil { @@ -93,9 +96,6 @@ func (d *Deployment) ServiceSpec(name string) (api.ServiceSpec, error) { // - 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? diff --git a/pkg/client/compose/project.go b/pkg/client/compose/project.go index 71ef0b87..5b8d3b1e 100644 --- a/pkg/client/compose/project.go +++ b/pkg/client/compose/project.go @@ -18,7 +18,7 @@ func LoadProject(ctx context.Context, paths []string) (*types.Project, error) { composecli.WithDotEnv, // Get compose file path set by COMPOSE_FILE. composecli.WithConfigFileEnv, - // If none was selected, get default compose.yaml file from current dir or parent folders. + // If none was selected, get default compose-ports-long.yaml file from current dir or parent folders. composecli.WithDefaultConfigPath, composecli.WithExtension(PortsExtensionKey, PortsSource{}), ) diff --git a/pkg/client/deploy.go b/pkg/client/deploy.go index ef30b745..d593b1cf 100644 --- a/pkg/client/deploy.go +++ b/pkg/client/deploy.go @@ -1,12 +1,15 @@ package client import ( + "context" "github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/client/deploy" ) // NewDeployment creates a new deployment for the given service specification. // 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) +func (cli *Client) NewDeployment( + ctx context.Context, spec api.ServiceSpec, strategy deploy.Strategy, +) (*deploy.Deployment, error) { + return deploy.NewDeployment(ctx, cli, spec, strategy) } diff --git a/pkg/client/deploy/container.go b/pkg/client/deploy/container.go index d2ae708a..1ed0fd59 100644 --- a/pkg/client/deploy/container.go +++ b/pkg/client/deploy/container.go @@ -24,6 +24,8 @@ func CompareContainerToSpec(ctr api.Container, spec api.ServiceSpec) (ContainerS } // TODO: compare mutable properties such as memory or CPU limits when they are implemented. + + // TODO: compare ports return ContainerUpToDate, nil } diff --git a/pkg/client/deploy/deploy.go b/pkg/client/deploy/deploy.go index 6c1700dc..c45cd1f5 100644 --- a/pkg/client/deploy/deploy.go +++ b/pkg/client/deploy/deploy.go @@ -10,6 +10,7 @@ import ( type Client interface { api.ContainerClient + api.DNSClient api.MachineClient api.ServiceClient } @@ -17,11 +18,12 @@ type Client interface { // 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 + Service *api.Service + Spec api.ServiceSpec + Strategy Strategy + cli Client + specResolver *ServiceSpecResolver + plan *Plan } type Plan struct { @@ -38,16 +40,27 @@ 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 { +func NewDeployment(ctx context.Context, cli Client, spec api.ServiceSpec, strategy Strategy) (*Deployment, error) { if strategy == nil { strategy = &RollingStrategy{} } - return &Deployment{ - Spec: spec, - Strategy: strategy, - cli: cli, + clusterDomain, err := cli.GetDomain(ctx) + if err != nil && !errors.Is(err, api.ErrNotFound) { + return nil, fmt.Errorf("get cluster domain: %w", err) } + + specResolver := &ServiceSpecResolver{ + // If the domain is not found (not reserved), an empty domain is used for the resolver. + ClusterDomain: clusterDomain, + } + + return &Deployment{ + Spec: spec, + Strategy: strategy, + cli: cli, + specResolver: specResolver, + }, nil } // Plan returns a plan of operations to reconcile the service to the desired state. @@ -57,12 +70,17 @@ func (d *Deployment) Plan(ctx context.Context) (Plan, error) { return *d.plan, nil } - // Validate the new spec before planning. + // Validate the user-provided spec before resolving it. 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) + resolvedSpec, err := d.specResolver.Resolve(d.Spec) + if err != nil { + return Plan{}, fmt.Errorf("resolve service spec: %w", err) + } + + plan, err := d.Strategy.Plan(ctx, d.cli, d.Service, resolvedSpec) if err != nil { return Plan{}, fmt.Errorf("create plan using %s strategy: %w", d.Strategy.Type(), err) } @@ -76,9 +94,6 @@ 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) @@ -96,10 +111,19 @@ func (d *Deployment) Validate(ctx context.Context) error { if d.Service.Name != d.Spec.Name { return errors.New("service name cannot be changed") } - if d.Service.Mode != d.Spec.Mode { + + // Resolve the default mode if not specified. + mode := d.Spec.Mode + if mode == "" { + mode = api.ServiceModeReplicated + } + + if mode != d.Service.Mode { return errors.New("service mode cannot be changed") } - if d.Spec.Mode == api.ServiceModeReplicated && d.Spec.Replicas < 1 { + if mode == api.ServiceModeReplicated && d.Spec.Replicas < 1 { + // Scaling down to zero is not allowed as this would effectively remove the service without preserving + // its configuration, making it impossible to scale back up. return errors.New("number of replicas must be at least 1") } diff --git a/pkg/client/deploy/resolver.go b/pkg/client/deploy/resolver.go index f5cf612f..bac60137 100644 --- a/pkg/client/deploy/resolver.go +++ b/pkg/client/deploy/resolver.go @@ -19,11 +19,13 @@ type ServiceSpecResolver struct { } // Resolve transforms a service spec into its fully resolved form ready for deployment. -func (r *ServiceSpecResolver) Resolve(spec *api.ServiceSpec) error { +func (r *ServiceSpecResolver) Resolve(spec api.ServiceSpec) (api.ServiceSpec, error) { if err := spec.Validate(); err != nil { - return fmt.Errorf("invalid service spec: %w", err) + return spec, fmt.Errorf("invalid service spec: %w", err) } + spec = spec.Clone() + steps := []func(*api.ServiceSpec) error{ r.applyDefaults, r.resolveServiceName, @@ -32,12 +34,12 @@ func (r *ServiceSpecResolver) Resolve(spec *api.ServiceSpec) error { } for _, step := range steps { - if err := step(spec); err != nil { - return err + if err := step(&spec); err != nil { + return spec, err } } - return nil + return spec, nil } func (r *ServiceSpecResolver) applyDefaults(spec *api.ServiceSpec) error { @@ -52,6 +54,7 @@ func (r *ServiceSpecResolver) applyDefaults(spec *api.ServiceSpec) error { return nil } +// resolveServiceName generates a service name from the image when not provided. func (r *ServiceSpecResolver) resolveServiceName(spec *api.ServiceSpec) error { if spec.Name != "" { return nil diff --git a/pkg/client/service.go b/pkg/client/service.go index 1270a028..1603cece 100644 --- a/pkg/client/service.go +++ b/pkg/client/service.go @@ -17,25 +17,6 @@ import ( "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, api.ErrNotFound) { - return spec, fmt.Errorf("get cluster domain: %w", err) - } - - 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. - } - - if err = resolver.Resolve(&spec); err != nil { - return spec, err - } - - return spec, nil -} - type RunServiceResponse struct { ID string Name string @@ -61,15 +42,18 @@ func (cli *Client) RunService( } } - var err error - if spec, err = cli.PrepareDeploymentSpec(ctx, spec); err != nil { - return resp, fmt.Errorf("prepare service spec ready for deployment: %w", err) + deployment, err := cli.NewDeployment(ctx, spec, &deploy.RollingStrategy{MachineFilter: filter}) + if err != nil { + return resp, fmt.Errorf("create deployment: %w", err) + } + + plan, err := deployment.Plan(ctx) + if err != nil { + return resp, fmt.Errorf("plan deployment: %w", err) } err = progress.RunWithTitle(ctx, func(ctx context.Context) error { - deployment := cli.NewDeployment(spec, &deploy.RollingStrategy{MachineFilter: filter}) - - plan, err := deployment.Run(ctx) + _, err = deployment.Run(ctx) if err != nil { return err } @@ -78,7 +62,7 @@ func (cli *Client) RunService( resp.Name = plan.ServiceName return nil - }, cli.progressOut(), fmt.Sprintf("Running service %s (%s mode)", spec.Name, spec.Mode)) + }, cli.progressOut(), fmt.Sprintf("Running service %s (%s mode)", plan.ServiceName, spec.Mode)) return resp, err } diff --git a/test/e2e/assert.go b/test/e2e/assert.go index da22dc96..9ea9f34c 100644 --- a/test/e2e/assert.go +++ b/test/e2e/assert.go @@ -11,10 +11,12 @@ import ( func assertServiceMatchesSpec(t *testing.T, svc api.Service, spec api.ServiceSpec) { assert.Equal(t, spec.Name, svc.Name) - assert.Equal(t, spec.Mode, svc.Mode) if svc.Mode == api.ServiceModeReplicated { + assert.Contains(t, []string{"", api.ServiceModeReplicated}, spec.Mode) assert.Len(t, svc.Containers, int(spec.Replicas), "Expected %d replicas", spec.Replicas) + } else { + assert.Equal(t, spec.Mode, svc.Mode) } for _, mc := range svc.Containers { diff --git a/test/e2e/service_test.go b/test/e2e/service_test.go index 589beffa..e93b4ef3 100644 --- a/test/e2e/service_test.go +++ b/test/e2e/service_test.go @@ -37,10 +37,10 @@ func TestDeployment(t *testing.T) { cli, err := c.Machines[0].Connect(ctx) require.NoError(t, err) - t.Run("global", func(t *testing.T) { + t.Run("global auto-generated name", func(t *testing.T) { t.Parallel() - name := "global-deployment" + name := "" // auto-generated and updated t.Cleanup(func() { err := cli.RemoveService(ctx, name) if !errors.Is(err, api.ErrNotFound) { @@ -52,26 +52,30 @@ func TestDeployment(t *testing.T) { }) spec := api.ServiceSpec{ - Name: name, Mode: api.ServiceModeGlobal, Container: api.ContainerSpec{ Image: "portainer/pause:latest", }, } - deployment := cli.NewDeployment(spec, nil) + deployment, err := cli.NewDeployment(ctx, spec, nil) + require.NoError(t, err) + err = deployment.Validate(ctx) require.NoError(t, err) plan, err := deployment.Plan(ctx) require.NoError(t, err) assert.NotEmpty(t, plan.ServiceID) - assert.Equal(t, name, plan.ServiceName) + assert.NotEmpty(t, plan.ServiceName) assert.Len(t, plan.SequenceOperation.Operations, 3) // 3 run runPlan, err := deployment.Run(ctx) require.NoError(t, err) assert.Equal(t, plan, runPlan) + name = plan.ServiceName + spec.Name = name // update spec to match the service and assert with assertServiceMatchesSpec + svc, err := cli.InspectService(ctx, name) require.NoError(t, err) assertServiceMatchesSpec(t, svc, spec) @@ -98,7 +102,9 @@ func TestDeployment(t *testing.T) { }, }, } - deployment = cli.NewDeployment(specWithPort, nil) + deployment, err = cli.NewDeployment(ctx, specWithPort, nil) + require.NoError(t, err) + plan, err = deployment.Plan(ctx) require.NoError(t, err) assert.Len(t, plan.SequenceOperation.Operations, 6) // 3 run + 3 remove @@ -137,7 +143,9 @@ func TestDeployment(t *testing.T) { }, }, } - deployment = cli.NewDeployment(specWithPortAndInit, nil) + deployment, err = cli.NewDeployment(ctx, specWithPortAndInit, nil) + require.NoError(t, err) + plan, err = deployment.Plan(ctx) require.NoError(t, err) assert.Len(t, plan.SequenceOperation.Operations, 9) // 3 stop + 3 run + 3 remove @@ -159,7 +167,9 @@ func TestDeployment(t *testing.T) { // Deploying the same spec should be a no-op. initialContainers = containers - deployment = cli.NewDeployment(specWithPortAndInit, nil) + deployment, err = cli.NewDeployment(ctx, specWithPortAndInit, nil) + require.NoError(t, err) + plan, err = deployment.Plan(ctx) require.NoError(t, err) assert.Len(t, plan.SequenceOperation.Operations, 0) // no-op @@ -194,7 +204,9 @@ func TestDeployment(t *testing.T) { }, } - deployment := cli.NewDeployment(spec, nil) + deployment, err := cli.NewDeployment(ctx, spec, nil) + require.NoError(t, err) + _, err = deployment.Run(ctx) require.NoError(t, err) @@ -218,7 +230,9 @@ func TestDeployment(t *testing.T) { } strategy := &deploy.RollingStrategy{MachineFilter: filter} - deployment = cli.NewDeployment(specWithInit, strategy) + deployment, err = cli.NewDeployment(ctx, specWithInit, strategy) + require.NoError(t, err) + _, err = deployment.Run(ctx) require.NoError(t, err) @@ -264,7 +278,9 @@ func TestDeployment(t *testing.T) { }, } - deployment = cli.NewDeployment(specWithPort, nil) + deployment, err = cli.NewDeployment(ctx, specWithPort, nil) + require.NoError(t, err) + _, err = deployment.Run(ctx) require.NoError(t, err) @@ -294,7 +310,7 @@ func TestDeployment(t *testing.T) { } }) - deployment, err := cli.NewCaddyDeployment("", nil) + deployment, err := cli.NewCaddyDeployment(ctx, "", nil) require.NoError(t, err) _, err = deployment.Run(ctx) @@ -346,8 +362,9 @@ func TestDeployment(t *testing.T) { return m.Name == c.Machines[0].Name } - deployment, err := cli.NewCaddyDeployment("", filter) + deployment, err := cli.NewCaddyDeployment(ctx, "", filter) require.NoError(t, err) + image := deployment.Spec.Container.Image _, err = deployment.Run(ctx) require.NoError(t, err) @@ -366,7 +383,7 @@ func TestDeployment(t *testing.T) { return m.Name == c.Machines[0].Name || m.Name == c.Machines[2].Name } - deployment, err = cli.NewCaddyDeployment("", filter) + deployment, err = cli.NewCaddyDeployment(ctx, image, filter) require.NoError(t, err) _, err = deployment.Run(ctx) @@ -411,7 +428,9 @@ func TestDeployment(t *testing.T) { Replicas: 2, } - deployment := cli.NewDeployment(spec, nil) + deployment, err := cli.NewDeployment(ctx, spec, nil) + require.NoError(t, err) + err = deployment.Validate(ctx) require.NoError(t, err) @@ -440,7 +459,9 @@ func TestDeployment(t *testing.T) { updatedSpec := spec updatedSpec.Container.Init = &init - deployment = cli.NewDeployment(updatedSpec, nil) + deployment, err = cli.NewDeployment(ctx, updatedSpec, nil) + require.NoError(t, err) + plan, err = deployment.Plan(ctx) require.NoError(t, err) assert.Len(t, plan.Operations, 4, "Expected 2 run + 2 remove operations") @@ -467,7 +488,9 @@ func TestDeployment(t *testing.T) { threeReplicaSpec := updatedSpec threeReplicaSpec.Replicas = 3 - deployment = cli.NewDeployment(threeReplicaSpec, nil) + deployment, err = cli.NewDeployment(ctx, threeReplicaSpec, nil) + require.NoError(t, err) + plan, err = deployment.Plan(ctx) require.NoError(t, err) assert.Len(t, plan.Operations, 1, "Expected 1 run operation") @@ -492,7 +515,9 @@ func TestDeployment(t *testing.T) { fourReplicaSpec.Container.Command = []string{"updated"} fourReplicaSpec.Replicas = 5 - deployment = cli.NewDeployment(fourReplicaSpec, nil) + deployment, err = cli.NewDeployment(ctx, fourReplicaSpec, nil) + require.NoError(t, err) + plan, err = deployment.Plan(ctx) require.NoError(t, err) assert.Len(t, plan.Operations, 8, "Expected 5 run + 3 remove operations") @@ -519,7 +544,9 @@ func TestDeployment(t *testing.T) { // 5. Redeploy the exact same spec and verify it's a noop. initialContainers = containers // Reset container tracking. - deployment = cli.NewDeployment(fourReplicaSpec, nil) + deployment, err = cli.NewDeployment(ctx, fourReplicaSpec, nil) + require.NoError(t, err) + plan, err = deployment.Plan(ctx) require.NoError(t, err) assert.Empty(t, plan.Operations, "Redeploying the same spec should be a no-op") @@ -560,7 +587,9 @@ func TestDeployment(t *testing.T) { } strategy := &deploy.RollingStrategy{MachineFilter: machine01Filter} - deployment := cli.NewDeployment(spec, strategy) + deployment, err := cli.NewDeployment(ctx, spec, strategy) + require.NoError(t, err) + _, err = deployment.Run(ctx) require.NoError(t, err) @@ -588,7 +617,9 @@ func TestDeployment(t *testing.T) { } strategy = &deploy.RollingStrategy{MachineFilter: machine2Filter} - deployment = cli.NewDeployment(spec, strategy) + deployment, err = cli.NewDeployment(ctx, spec, strategy) + require.NoError(t, err) + _, err = deployment.Run(ctx) require.NoError(t, err) @@ -756,8 +787,16 @@ func TestServiceLifecycle(t *testing.T) { assert.Contains(t, ctr.NetworkSettings.Networks, machinedocker.NetworkName) }) - // TODO: create container invalid spec. - // - service Name is required + t.Run("create container invalid service", func(t *testing.T) { + t.Parallel() + + invalidIDs := []string{"", "invalid", "651aef23ae90"} + + for _, invalidID := range invalidIDs { + _, err := cli.CreateContainer(ctx, invalidID, api.ServiceSpec{}, c.Machines[0].Name) + require.ErrorContains(t, err, "invalid service ID") + } + }) t.Run("container lifecycle", func(t *testing.T) { t.Parallel()