diff --git a/cli/command/service/create.go b/cli/command/service/create.go index 31dc3a565a..6e49558609 100644 --- a/cli/command/service/create.go +++ b/cli/command/service/create.go @@ -137,7 +137,7 @@ func runCreate(ctx context.Context, dockerCli command.Cli, flags *pflag.FlagSet, return nil } - return waitOnService(ctx, dockerCli, response.ID, opts.quiet) + return WaitOnService(ctx, dockerCli, response.ID, opts.quiet) } // setConfigs does double duty: it both sets the ConfigReferences of the diff --git a/cli/command/service/helpers.go b/cli/command/service/helpers.go index a104f19fd3..a8ff310a57 100644 --- a/cli/command/service/helpers.go +++ b/cli/command/service/helpers.go @@ -9,9 +9,9 @@ import ( "github.com/docker/docker/pkg/jsonmessage" ) -// waitOnService waits for the service to converge. It outputs a progress bar, +// WaitOnService waits for the service to converge. It outputs a progress bar, // if appropriate based on the CLI flags. -func waitOnService(ctx context.Context, dockerCli command.Cli, serviceID string, quiet bool) error { +func WaitOnService(ctx context.Context, dockerCli command.Cli, serviceID string, quiet bool) error { errChan := make(chan error, 1) pipeReader, pipeWriter := io.Pipe() diff --git a/cli/command/service/progress/progress.go b/cli/command/service/progress/progress.go index 11436370d1..ed3f36ff5d 100644 --- a/cli/command/service/progress/progress.go +++ b/cli/command/service/progress/progress.go @@ -143,7 +143,7 @@ func ServiceProgress(ctx context.Context, apiClient client.APIClient, serviceID if converged && time.Since(convergedAt) >= monitor { progressOut.WriteProgress(progress.Progress{ ID: "verify", - Action: "Service converged", + Action: fmt.Sprintf("Service %s converged", serviceID), }) if message != nil { progressOut.WriteProgress(*message) diff --git a/cli/command/service/rollback.go b/cli/command/service/rollback.go index fb963d1355..06be65bb90 100644 --- a/cli/command/service/rollback.go +++ b/cli/command/service/rollback.go @@ -62,5 +62,5 @@ func runRollback(ctx context.Context, dockerCli command.Cli, options *serviceOpt return nil } - return waitOnService(ctx, dockerCli, serviceID, options.quiet) + return WaitOnService(ctx, dockerCli, serviceID, options.quiet) } diff --git a/cli/command/service/scale.go b/cli/command/service/scale.go index 0f5b62ca16..fd86304989 100644 --- a/cli/command/service/scale.go +++ b/cli/command/service/scale.go @@ -80,7 +80,7 @@ func runScale(ctx context.Context, dockerCli command.Cli, options *scaleOptions, if len(serviceIDs) > 0 { if !options.detach && versions.GreaterThanOrEqualTo(dockerCli.Client().ClientVersion(), "1.29") { for _, serviceID := range serviceIDs { - if err := waitOnService(ctx, dockerCli, serviceID, false); err != nil { + if err := WaitOnService(ctx, dockerCli, serviceID, false); err != nil { errs = append(errs, fmt.Sprintf("%s: %v", serviceID, err)) } } diff --git a/cli/command/service/update.go b/cli/command/service/update.go index f4e7357b1c..d25229802e 100644 --- a/cli/command/service/update.go +++ b/cli/command/service/update.go @@ -249,7 +249,7 @@ func runUpdate(ctx context.Context, dockerCli command.Cli, flags *pflag.FlagSet, return nil } - return waitOnService(ctx, dockerCli, serviceID, options.quiet) + return WaitOnService(ctx, dockerCli, serviceID, options.quiet) } //nolint:gocyclo diff --git a/cli/command/stack/deploy.go b/cli/command/stack/deploy.go index 8238e4b464..bb7058dd34 100644 --- a/cli/command/stack/deploy.go +++ b/cli/command/stack/deploy.go @@ -26,7 +26,7 @@ func newDeployCommand(dockerCli command.Cli) *cobra.Command { if err != nil { return err } - return swarm.RunDeploy(cmd.Context(), dockerCli, opts, config) + return swarm.RunDeploy(cmd.Context(), dockerCli, cmd.Flags(), &opts, config) }, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return completeNames(dockerCli)(cmd, args, toComplete) @@ -42,5 +42,7 @@ func newDeployCommand(dockerCli command.Cli) *cobra.Command { flags.StringVar(&opts.ResolveImage, "resolve-image", swarm.ResolveImageAlways, `Query the registry to resolve image digest and supported platforms ("`+swarm.ResolveImageAlways+`", "`+swarm.ResolveImageChanged+`", "`+swarm.ResolveImageNever+`")`) flags.SetAnnotation("resolve-image", "version", []string{"1.30"}) + flags.BoolVarP(&opts.Detach, "detach", "d", true, "Exit immediately instead of waiting for the stack services to converge") + flags.BoolVarP(&opts.Quiet, "quiet", "q", false, "Suppress progress output") return cmd } diff --git a/cli/command/stack/options/opts.go b/cli/command/stack/options/opts.go index 9c0cece153..263b493b6a 100644 --- a/cli/command/stack/options/opts.go +++ b/cli/command/stack/options/opts.go @@ -9,6 +9,8 @@ type Deploy struct { ResolveImage string SendRegistryAuth bool Prune bool + Detach bool + Quiet bool } // Config holds docker stack config options diff --git a/cli/command/stack/swarm/deploy.go b/cli/command/stack/swarm/deploy.go index c6159dc21a..7fe52f67fd 100644 --- a/cli/command/stack/swarm/deploy.go +++ b/cli/command/stack/swarm/deploy.go @@ -11,6 +11,7 @@ import ( "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/versions" "github.com/pkg/errors" + "github.com/spf13/pflag" ) // Resolve image constants @@ -22,8 +23,8 @@ const ( ) // RunDeploy is the swarm implementation of docker stack deploy -func RunDeploy(ctx context.Context, dockerCli command.Cli, opts options.Deploy, cfg *composetypes.Config) error { - if err := validateResolveImageFlag(&opts); err != nil { +func RunDeploy(ctx context.Context, dockerCli command.Cli, flags *pflag.FlagSet, opts *options.Deploy, cfg *composetypes.Config) error { + if err := validateResolveImageFlag(opts); err != nil { return err } // client side image resolution should not be done when the supported @@ -32,6 +33,11 @@ func RunDeploy(ctx context.Context, dockerCli command.Cli, opts options.Deploy, opts.ResolveImage = ResolveImageNever } + if opts.Detach && !flags.Changed("detach") { + fmt.Fprintln(dockerCli.Err(), "Since --detach=false was not specified, tasks will be created in the background.\n"+ + "In a future release, --detach=false will become the default.") + } + return deployCompose(ctx, dockerCli, opts, cfg) } diff --git a/cli/command/stack/swarm/deploy_composefile.go b/cli/command/stack/swarm/deploy_composefile.go index 73810aad8f..290396f14b 100644 --- a/cli/command/stack/swarm/deploy_composefile.go +++ b/cli/command/stack/swarm/deploy_composefile.go @@ -2,9 +2,11 @@ package swarm import ( "context" + "errors" "fmt" "github.com/docker/cli/cli/command" + servicecli "github.com/docker/cli/cli/command/service" "github.com/docker/cli/cli/command/stack/options" "github.com/docker/cli/cli/compose/convert" composetypes "github.com/docker/cli/cli/compose/types" @@ -13,10 +15,9 @@ import ( "github.com/docker/docker/api/types/swarm" apiclient "github.com/docker/docker/client" "github.com/docker/docker/errdefs" - "github.com/pkg/errors" ) -func deployCompose(ctx context.Context, dockerCli command.Cli, opts options.Deploy, config *composetypes.Config) error { +func deployCompose(ctx context.Context, dockerCli command.Cli, opts *options.Deploy, config *composetypes.Config) error { if err := checkDaemonIsSwarmManager(ctx, dockerCli); err != nil { return err } @@ -60,7 +61,17 @@ func deployCompose(ctx context.Context, dockerCli command.Cli, opts options.Depl if err != nil { return err } - return deployServices(ctx, dockerCli, services, namespace, opts.SendRegistryAuth, opts.ResolveImage) + + serviceIDs, err := deployServices(ctx, dockerCli, services, namespace, opts.SendRegistryAuth, opts.ResolveImage) + if err != nil { + return err + } + + if opts.Detach { + return nil + } + + return waitOnServices(ctx, dockerCli, serviceIDs, opts.Quiet) } func getServicesDeclaredNetworks(serviceConfigs []composetypes.ServiceConfig) map[string]struct{} { @@ -87,11 +98,11 @@ func validateExternalNetworks(ctx context.Context, client apiclient.NetworkAPICl network, err := client.NetworkInspect(ctx, networkName, types.NetworkInspectOptions{}) switch { case errdefs.IsNotFound(err): - return errors.Errorf("network %q is declared as external, but could not be found. You need to create a swarm-scoped network before the stack is deployed", networkName) + return fmt.Errorf("network %q is declared as external, but could not be found. You need to create a swarm-scoped network before the stack is deployed", networkName) case err != nil: return err case network.Scope != "swarm": - return errors.Errorf("network %q is declared as external, but it is not in the right scope: %q instead of \"swarm\"", networkName, network.Scope) + return fmt.Errorf("network %q is declared as external, but it is not in the right scope: %q instead of \"swarm\"", networkName, network.Scope) } } return nil @@ -106,13 +117,13 @@ func createSecrets(ctx context.Context, dockerCli command.Cli, secrets []swarm.S case err == nil: // secret already exists, then we update that if err := client.SecretUpdate(ctx, secret.ID, secret.Meta.Version, secretSpec); err != nil { - return errors.Wrapf(err, "failed to update secret %s", secretSpec.Name) + return fmt.Errorf("failed to update secret %s: %w", secretSpec.Name, err) } case errdefs.IsNotFound(err): // secret does not exist, then we create a new one. fmt.Fprintf(dockerCli.Out(), "Creating secret %s\n", secretSpec.Name) if _, err := client.SecretCreate(ctx, secretSpec); err != nil { - return errors.Wrapf(err, "failed to create secret %s", secretSpec.Name) + return fmt.Errorf("failed to create secret %s: %w", secretSpec.Name, err) } default: return err @@ -130,13 +141,13 @@ func createConfigs(ctx context.Context, dockerCli command.Cli, configs []swarm.C case err == nil: // config already exists, then we update that if err := client.ConfigUpdate(ctx, config.ID, config.Meta.Version, configSpec); err != nil { - return errors.Wrapf(err, "failed to update config %s", configSpec.Name) + return fmt.Errorf("failed to update config %s: %w", configSpec.Name, err) } case errdefs.IsNotFound(err): // config does not exist, then we create a new one. fmt.Fprintf(dockerCli.Out(), "Creating config %s\n", configSpec.Name) if _, err := client.ConfigCreate(ctx, configSpec); err != nil { - return errors.Wrapf(err, "failed to create config %s", configSpec.Name) + return fmt.Errorf("failed to create config %s: %w", configSpec.Name, err) } default: return err @@ -169,19 +180,19 @@ func createNetworks(ctx context.Context, dockerCli command.Cli, namespace conver fmt.Fprintf(dockerCli.Out(), "Creating network %s\n", name) if _, err := client.NetworkCreate(ctx, name, createOpts); err != nil { - return errors.Wrapf(err, "failed to create network %s", name) + return fmt.Errorf("failed to create network %s: %w", name, err) } } return nil } -func deployServices(ctx context.Context, dockerCli command.Cli, services map[string]swarm.ServiceSpec, namespace convert.Namespace, sendAuth bool, resolveImage string) error { +func deployServices(ctx context.Context, dockerCli command.Cli, services map[string]swarm.ServiceSpec, namespace convert.Namespace, sendAuth bool, resolveImage string) ([]string, error) { apiClient := dockerCli.Client() out := dockerCli.Out() existingServices, err := getStackServices(ctx, apiClient, namespace.Name()) if err != nil { - return err + return nil, err } existingServiceMap := make(map[string]swarm.Service) @@ -189,6 +200,8 @@ func deployServices(ctx context.Context, dockerCli command.Cli, services map[str existingServiceMap[service.Spec.Name] = service } + var serviceIDs []string + for internalName, serviceSpec := range services { var ( name = namespace.Scope(internalName) @@ -200,7 +213,7 @@ func deployServices(ctx context.Context, dockerCli command.Cli, services map[str // Retrieve encoded auth token from the image reference encodedAuth, err = command.RetrieveAuthTokenFromImage(dockerCli.ConfigFile(), image) if err != nil { - return err + return nil, err } } @@ -241,12 +254,14 @@ func deployServices(ctx context.Context, dockerCli command.Cli, services map[str response, err := apiClient.ServiceUpdate(ctx, service.ID, service.Version, serviceSpec, updateOpts) if err != nil { - return errors.Wrapf(err, "failed to update service %s", name) + return nil, fmt.Errorf("failed to update service %s: %w", name, err) } for _, warning := range response.Warnings { fmt.Fprintln(dockerCli.Err(), warning) } + + serviceIDs = append(serviceIDs, service.ID) } else { fmt.Fprintf(out, "Creating service %s\n", name) @@ -257,10 +272,29 @@ func deployServices(ctx context.Context, dockerCli command.Cli, services map[str createOpts.QueryRegistry = true } - if _, err := apiClient.ServiceCreate(ctx, serviceSpec, createOpts); err != nil { - return errors.Wrapf(err, "failed to create service %s", name) + response, err := apiClient.ServiceCreate(ctx, serviceSpec, createOpts) + if err != nil { + return nil, fmt.Errorf("failed to create service %s: %w", name, err) } + + serviceIDs = append(serviceIDs, response.ID) } } + + return serviceIDs, nil +} + +func waitOnServices(ctx context.Context, dockerCli command.Cli, serviceIDs []string, quiet bool) error { + var errs []error + for _, serviceID := range serviceIDs { + if err := servicecli.WaitOnService(ctx, dockerCli, serviceID, quiet); err != nil { + errs = append(errs, fmt.Errorf("%s: %w", serviceID, err)) + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil } diff --git a/cli/command/stack/swarm/deploy_test.go b/cli/command/stack/swarm/deploy_test.go index 5a29442eaa..112c48fa77 100644 --- a/cli/command/stack/swarm/deploy_test.go +++ b/cli/command/stack/swarm/deploy_test.go @@ -99,7 +99,7 @@ func TestServiceUpdateResolveImageChanged(t *testing.T) { }, }, } - err := deployServices(ctx, client, spec, namespace, false, ResolveImageChanged) + _, err := deployServices(ctx, client, spec, namespace, false, ResolveImageChanged) assert.NilError(t, err) assert.Check(t, is.Equal(receivedOptions.QueryRegistry, tc.expectedQueryRegistry)) assert.Check(t, is.Equal(receivedService.TaskTemplate.ContainerSpec.Image, tc.expectedImage)) diff --git a/docs/reference/commandline/stack_deploy.md b/docs/reference/commandline/stack_deploy.md index 9af21169c1..82934f6f92 100644 --- a/docs/reference/commandline/stack_deploy.md +++ b/docs/reference/commandline/stack_deploy.md @@ -12,7 +12,9 @@ Deploy a new stack or update an existing stack | Name | Type | Default | Description | |:---------------------------------------------------------|:--------------|:---------|:--------------------------------------------------------------------------------------------------| | [`-c`](#compose-file), [`--compose-file`](#compose-file) | `stringSlice` | | Path to a Compose file, or `-` to read from stdin | +| `-d`, `--detach` | `bool` | `true` | Exit immediately instead of waiting for the stack services to converge | | `--prune` | | | Prune services that are no longer referenced | +| `-q`, `--quiet` | | | Suppress progress output | | `--resolve-image` | `string` | `always` | Query the registry to resolve image digest and supported platforms (`always`, `changed`, `never`) | | `--with-registry-auth` | | | Send registry authentication details to Swarm agents | diff --git a/e2e/stack/testdata/stack-deploy-help.golden b/e2e/stack/testdata/stack-deploy-help.golden index 0a8d862e8b..3ebe9299f3 100644 --- a/e2e/stack/testdata/stack-deploy-help.golden +++ b/e2e/stack/testdata/stack-deploy-help.golden @@ -9,7 +9,10 @@ Aliases: Options: -c, --compose-file strings Path to a Compose file, or "-" to read from stdin + -d, --detach Exit immediately instead of waiting for + the stack services to converge (default true) --prune Prune services that are no longer referenced + -q, --quiet Suppress progress output --resolve-image string Query the registry to resolve image digest and supported platforms ("always", "changed", "never") (default "always")