From 2f139b5c15e5ce7e3fbb475cc0d3e25b1c1dfd1c Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Wed, 18 Mar 2026 21:45:00 +0000 Subject: [PATCH] Add `docker compose deploy` command Implements a production-oriented deploy command that builds, pushes, and applies a Compose project to a Docker server with force-recreate semantics and optional health-check-based wait for zero-downtime deployments. Signed-off-by: Eric Curtin --- cmd/compose/compose.go | 1 + cmd/compose/deploy.go | 113 ++++++++++++++++++++ docs/reference/compose.md | 1 + docs/reference/compose_deploy.md | 28 +++++ docs/reference/docker_compose.yaml | 2 + docs/reference/docker_compose_deploy.yaml | 105 ++++++++++++++++++ pkg/api/api.go | 20 ++++ pkg/compose/deploy.go | 58 ++++++++++ pkg/e2e/deploy_test.go | 124 ++++++++++++++++++++++ pkg/e2e/fixtures/deploy/compose.yaml | 11 ++ 10 files changed, 463 insertions(+) create mode 100644 cmd/compose/deploy.go create mode 100644 docs/reference/compose_deploy.md create mode 100644 docs/reference/docker_compose_deploy.yaml create mode 100644 pkg/compose/deploy.go create mode 100644 pkg/e2e/deploy_test.go create mode 100644 pkg/e2e/fixtures/deploy/compose.yaml diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 2b4bcb638ee..1a14a28ad1e 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -615,6 +615,7 @@ func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.C statsCommand(&opts, dockerCli), watchCommand(&opts, dockerCli, backendOptions), publishCommand(&opts, dockerCli, backendOptions), + deployCommand(&opts, dockerCli, backendOptions), alphaCommand(&opts, dockerCli, backendOptions), bridgeCommand(&opts, dockerCli), volumesCommand(&opts, dockerCli, backendOptions), diff --git a/cmd/compose/deploy.go b/cmd/compose/deploy.go new file mode 100644 index 00000000000..acf39fcf523 --- /dev/null +++ b/cmd/compose/deploy.go @@ -0,0 +1,113 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "context" + "fmt" + "time" + + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" + + "github.com/docker/compose/v5/pkg/api" + "github.com/docker/compose/v5/pkg/compose" +) + +type deployOptions struct { + *ProjectOptions + composeOptions + build bool + noBuild bool + push bool + quiet bool + removeOrphans bool + wait bool + waitTimeout int +} + +func deployCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command { + opts := deployOptions{ + ProjectOptions: p, + } + cmd := &cobra.Command{ + Use: "deploy [OPTIONS] [SERVICE...]", + Short: "Deploy a Compose application to a Docker server", + Long: `Deploy a Compose application to a Docker server. + +This command applies the Compose project to the target Docker server, +recreating containers with updated configuration and images. Images are +pulled from the registry unless --build is specified. + +Use health checks defined in the Compose file to ensure zero-downtime +deployments by passing --wait.`, + PreRunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error { + if opts.waitTimeout < 0 { + return fmt.Errorf("--wait-timeout must be a non-negative integer") + } + if opts.build && opts.noBuild { + return fmt.Errorf("--build and --no-build are incompatible") + } + return nil + }), + RunE: Adapt(func(ctx context.Context, args []string) error { + return runDeploy(ctx, dockerCli, backendOptions, opts, args) + }), + ValidArgsFunction: completeServiceNames(dockerCli, p), + } + flags := cmd.Flags() + flags.BoolVar(&opts.build, "build", false, "Build images before deploying") + flags.BoolVar(&opts.noBuild, "no-build", false, "Do not build images even if build configuration is defined") + flags.BoolVar(&opts.push, "push", false, "Push images to registry before deploying") + flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Suppress pull/push progress output") + flags.BoolVar(&opts.removeOrphans, "remove-orphans", false, "Remove containers for services not defined in the Compose file") + flags.BoolVar(&opts.wait, "wait", false, "Wait for services to be healthy before returning") + flags.IntVar(&opts.waitTimeout, "wait-timeout", 0, "Maximum duration in seconds to wait for services to be healthy (0 = no timeout)") + return cmd +} + +func runDeploy(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts deployOptions, services []string) error { + backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...) + if err != nil { + return err + } + + project, _, err := opts.ToProject(ctx, dockerCli, backend, services) + if err != nil { + return err + } + + deployOpts := api.DeployOptions{ + Push: opts.push, + Quiet: opts.quiet, + RemoveOrphans: opts.removeOrphans, + Wait: opts.wait, + Services: services, + } + + if opts.waitTimeout > 0 { + deployOpts.WaitTimeout = time.Duration(opts.waitTimeout) * time.Second + } + + if opts.build && !opts.noBuild { + deployOpts.Build = &api.BuildOptions{ + Services: services, + } + } + + return backend.Deploy(ctx, project, deployOpts) +} diff --git a/docs/reference/compose.md b/docs/reference/compose.md index d80bb86ec62..1de0f5c0be5 100644 --- a/docs/reference/compose.md +++ b/docs/reference/compose.md @@ -19,6 +19,7 @@ Define and run multi-container applications with Docker | [`config`](compose_config.md) | Parse, resolve and render compose file in canonical format | | [`cp`](compose_cp.md) | Copy files/folders between a service container and the local filesystem | | [`create`](compose_create.md) | Creates containers for a service | +| [`deploy`](compose_deploy.md) | Deploy a Compose application to a Docker server | | [`down`](compose_down.md) | Stop and remove containers, networks | | [`events`](compose_events.md) | Receive real time events from containers | | [`exec`](compose_exec.md) | Execute a command in a running container | diff --git a/docs/reference/compose_deploy.md b/docs/reference/compose_deploy.md new file mode 100644 index 00000000000..ffd56714bb6 --- /dev/null +++ b/docs/reference/compose_deploy.md @@ -0,0 +1,28 @@ +# docker compose deploy + + +Deploy a Compose application to a Docker server. + +This command applies the Compose project to the target Docker server, +recreating containers with updated configuration and images. Images are +pulled from the registry unless --build is specified. + +Use health checks defined in the Compose file to ensure zero-downtime +deployments by passing --wait. + +### Options + +| Name | Type | Default | Description | +|:-------------------|:-------|:--------|:--------------------------------------------------------------------------------| +| `--build` | `bool` | | Build images before deploying | +| `--dry-run` | `bool` | | Execute command in dry run mode | +| `--no-build` | `bool` | | Do not build images even if build configuration is defined | +| `--push` | `bool` | | Push images to registry before deploying | +| `-q`, `--quiet` | `bool` | | Suppress pull/push progress output | +| `--remove-orphans` | `bool` | | Remove containers for services not defined in the Compose file | +| `--wait` | `bool` | | Wait for services to be healthy before returning | +| `--wait-timeout` | `int` | `0` | Maximum duration in seconds to wait for services to be healthy (0 = no timeout) | + + + + diff --git a/docs/reference/docker_compose.yaml b/docs/reference/docker_compose.yaml index c5fdb937510..2b4153ce3d9 100644 --- a/docs/reference/docker_compose.yaml +++ b/docs/reference/docker_compose.yaml @@ -12,6 +12,7 @@ cname: - docker compose config - docker compose cp - docker compose create + - docker compose deploy - docker compose down - docker compose events - docker compose exec @@ -48,6 +49,7 @@ clink: - docker_compose_config.yaml - docker_compose_cp.yaml - docker_compose_create.yaml + - docker_compose_deploy.yaml - docker_compose_down.yaml - docker_compose_events.yaml - docker_compose_exec.yaml diff --git a/docs/reference/docker_compose_deploy.yaml b/docs/reference/docker_compose_deploy.yaml new file mode 100644 index 00000000000..84eb8f20177 --- /dev/null +++ b/docs/reference/docker_compose_deploy.yaml @@ -0,0 +1,105 @@ +command: docker compose deploy +short: Deploy a Compose application to a Docker server +long: |- + Deploy a Compose application to a Docker server. + + This command applies the Compose project to the target Docker server, + recreating containers with updated configuration and images. Images are + pulled from the registry unless --build is specified. + + Use health checks defined in the Compose file to ensure zero-downtime + deployments by passing --wait. +usage: docker compose deploy [OPTIONS] [SERVICE...] +pname: docker compose +plink: docker_compose.yaml +options: + - option: build + value_type: bool + default_value: "false" + description: Build images before deploying + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: no-build + value_type: bool + default_value: "false" + description: Do not build images even if build configuration is defined + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: push + value_type: bool + default_value: "false" + description: Push images to registry before deploying + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: quiet + shorthand: q + value_type: bool + default_value: "false" + description: Suppress pull/push progress output + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: remove-orphans + value_type: bool + default_value: "false" + description: Remove containers for services not defined in the Compose file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: wait + value_type: bool + default_value: "false" + description: Wait for services to be healthy before returning + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: wait-timeout + value_type: int + default_value: "0" + description: | + Maximum duration in seconds to wait for services to be healthy (0 = no timeout) + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false +inherited_options: + - option: dry-run + value_type: bool + default_value: "false" + description: Execute command in dry run mode + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false +deprecated: false +hidden: false +experimental: false +experimentalcli: false +kubernetes: false +swarm: false + diff --git a/pkg/api/api.go b/pkg/api/api.go index deefc1e52e9..caa2614aa60 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -149,6 +149,26 @@ type Compose interface { Volumes(ctx context.Context, project string, options VolumesOptions) ([]VolumesSummary, error) // LoadProject loads and validates a Compose project from configuration files. LoadProject(ctx context.Context, options ProjectLoadOptions) (*types.Project, error) + // Deploy executes the equivalent to a `compose deploy` + Deploy(ctx context.Context, project *types.Project, options DeployOptions) error +} + +// DeployOptions group options of the Deploy API +type DeployOptions struct { + // Build rebuilds service images before deploying + Build *BuildOptions + // Push pushes images to the registry before deploying + Push bool + // Quiet suppresses pull/push progress output + Quiet bool + // RemoveOrphans removes containers for services not defined in the project + RemoveOrphans bool + // Services is the list of services to deploy (defaults to all) + Services []string + // Wait waits for services to be healthy after deploy + Wait bool + // WaitTimeout is the maximum time to wait for services to become healthy + WaitTimeout time.Duration } type VolumesOptions struct { diff --git a/pkg/compose/deploy.go b/pkg/compose/deploy.go new file mode 100644 index 00000000000..03c80bb0bc6 --- /dev/null +++ b/pkg/compose/deploy.go @@ -0,0 +1,58 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "context" + + "github.com/compose-spec/compose-go/v2/types" + + "github.com/docker/compose/v5/pkg/api" +) + +func (s *composeService) Deploy(ctx context.Context, project *types.Project, options api.DeployOptions) error { + if options.Build != nil { + if err := s.Build(ctx, project, *options.Build); err != nil { + return err + } + } + + if options.Push { + if err := s.Push(ctx, project, api.PushOptions{ + Quiet: options.Quiet, + }); err != nil { + return err + } + } + + return s.Up(ctx, project, api.UpOptions{ + Create: api.CreateOptions{ + Services: options.Services, + Recreate: api.RecreateForce, + RecreateDependencies: api.RecreateForce, + RemoveOrphans: options.RemoveOrphans, + Inherit: true, + QuietPull: options.Quiet, + }, + Start: api.StartOptions{ + Project: project, + Services: options.Services, + Wait: options.Wait, + WaitTimeout: options.WaitTimeout, + }, + }) +} diff --git a/pkg/e2e/deploy_test.go b/pkg/e2e/deploy_test.go new file mode 100644 index 00000000000..0a1cf5d2415 --- /dev/null +++ b/pkg/e2e/deploy_test.go @@ -0,0 +1,124 @@ +//go:build !windows + +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package e2e + +import ( + "strings" + "testing" + "time" + + "gotest.tools/v3/assert" +) + +func TestDeploy(t *testing.T) { + c := NewParallelCLI(t) + const projectName = "e2e-deploy" + + reset := func() { + c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "-v", "--remove-orphans") + } + reset() + t.Cleanup(reset) + + t.Log("Deploy the application") + c.RunDockerComposeCmd(t, "-f", "fixtures/deploy/compose.yaml", "--project-name", projectName, "deploy", "-d") + + t.Log("Verify service is running") + res := c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "--format", "json") + output := res.Stdout() + assert.Assert(t, strings.Contains(output, "running"), "Expected service to be running, got: %s", output) +} + +func TestDeployWait(t *testing.T) { + c := NewParallelCLI(t) + const projectName = "e2e-deploy-wait" + + reset := func() { + c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "-v", "--remove-orphans") + } + reset() + t.Cleanup(reset) + + t.Log("Deploy the application with --wait") + timeout := time.After(30 * time.Second) + done := make(chan bool) + go func() { + res := c.RunDockerComposeCmd(t, "-f", "fixtures/deploy/compose.yaml", "--project-name", projectName, "deploy", "--wait") + assert.Assert(t, strings.Contains(res.Combined(), projectName), "Expected project name in output") + done <- true + }() + + select { + case <-timeout: + t.Fatal("deploy --wait did not complete in time") + case <-done: + break + } + + t.Log("Verify service is healthy") + res := c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "--format", "json") + output := res.Stdout() + assert.Assert(t, strings.Contains(output, "running"), "Expected service to be running, got: %s", output) +} + +func TestDeployBuild(t *testing.T) { + c := NewParallelCLI(t) + const projectName = "e2e-deploy-build" + + reset := func() { + c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "-v", "--remove-orphans") + } + reset() + t.Cleanup(reset) + + t.Log("Deploy the application with --build") + c.RunDockerComposeCmd(t, "-f", "fixtures/deploy/compose.yaml", "--project-name", projectName, "deploy", "--build", "-d") + + t.Log("Verify service is running") + res := c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "--format", "json") + output := res.Stdout() + assert.Assert(t, strings.Contains(output, "running"), "Expected service to be running, got: %s", output) +} + +func TestDeployRemoveOrphans(t *testing.T) { + c := NewParallelCLI(t) + const projectName = "e2e-deploy-orphans" + + reset := func() { + c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "-v", "--remove-orphans") + } + reset() + t.Cleanup(reset) + + t.Log("Deploy the application") + c.RunDockerComposeCmd(t, "-f", "fixtures/deploy/compose.yaml", "--project-name", projectName, "deploy", "-d") + + t.Log("Verify service is running") + res := c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "--format", "json") + output := res.Stdout() + assert.Assert(t, strings.Contains(output, "running"), "Expected service to be running, got: %s", output) + + t.Log("Deploy with --remove-orphans") + c.RunDockerComposeCmd(t, "-f", "fixtures/deploy/compose.yaml", "--project-name", projectName, "deploy", "--remove-orphans", "-d") + + t.Log("Verify service is still running") + res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "--format", "json") + output = res.Stdout() + assert.Assert(t, strings.Contains(output, "running"), "Expected service to be running, got: %s", output) +} diff --git a/pkg/e2e/fixtures/deploy/compose.yaml b/pkg/e2e/fixtures/deploy/compose.yaml new file mode 100644 index 00000000000..b572904c67c --- /dev/null +++ b/pkg/e2e/fixtures/deploy/compose.yaml @@ -0,0 +1,11 @@ +services: + web: + image: nginx:alpine + ports: + - "8080:80" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/"] + interval: 1s + timeout: 3s + retries: 3 + start_period: 2s