From 73677146f4e987aeaef88c3520bc0595d52d8f06 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Mon, 1 Sep 2025 13:51:31 +0200 Subject: [PATCH 1/2] cli/command/stack: internalize GetConfigDetails, LoadComposefile These were deprecated in ad6ab189a642625d54053ac6e7080e8940288d2d and were only used internally. Move them back inside the stack package. Signed-off-by: Sebastiaan van Stijn --- cli/command/stack/config.go | 18 ++++++++++------- cli/command/stack/deploy.go | 3 +-- cli/command/stack/{loader => }/loader.go | 20 ++++++++----------- cli/command/stack/{loader => }/loader_test.go | 6 +++--- cli/command/stack/options/opts.go | 8 -------- 5 files changed, 23 insertions(+), 32 deletions(-) rename cli/command/stack/{loader => }/loader.go (86%) rename cli/command/stack/{loader => }/loader_test.go (92%) diff --git a/cli/command/stack/config.go b/cli/command/stack/config.go index 462aa447b6..36863f7ff9 100644 --- a/cli/command/stack/config.go +++ b/cli/command/stack/config.go @@ -6,28 +6,32 @@ import ( "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" - "github.com/docker/cli/cli/command/stack/loader" - "github.com/docker/cli/cli/command/stack/options" composeLoader "github.com/docker/cli/cli/compose/loader" composetypes "github.com/docker/cli/cli/compose/types" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) +// configOptions holds docker stack config options +type configOptions struct { + composeFiles []string + skipInterpolation bool +} + func newConfigCommand(dockerCLI command.Cli) *cobra.Command { - var opts options.Config + var opts configOptions cmd := &cobra.Command{ Use: "config [OPTIONS]", Short: "Outputs the final config file, after doing merges and interpolations", Args: cli.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - configDetails, err := loader.GetConfigDetails(opts.Composefiles, dockerCLI.In()) + configDetails, err := getConfigDetails(opts.composeFiles, dockerCLI.In()) if err != nil { return err } - cfg, err := outputConfig(configDetails, opts.SkipInterpolation) + cfg, err := outputConfig(configDetails, opts.skipInterpolation) if err != nil { return err } @@ -40,8 +44,8 @@ func newConfigCommand(dockerCLI command.Cli) *cobra.Command { } flags := cmd.Flags() - flags.StringSliceVarP(&opts.Composefiles, "compose-file", "c", []string{}, `Path to a Compose file, or "-" to read from stdin`) - flags.BoolVar(&opts.SkipInterpolation, "skip-interpolation", false, "Skip interpolation and output only merged config") + flags.StringSliceVarP(&opts.composeFiles, "compose-file", "c", []string{}, `Path to a Compose file, or "-" to read from stdin`) + flags.BoolVar(&opts.skipInterpolation, "skip-interpolation", false, "Skip interpolation and output only merged config") return cmd } diff --git a/cli/command/stack/deploy.go b/cli/command/stack/deploy.go index 82301d514c..a2ac0b637e 100644 --- a/cli/command/stack/deploy.go +++ b/cli/command/stack/deploy.go @@ -3,7 +3,6 @@ package stack import ( "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" - "github.com/docker/cli/cli/command/stack/loader" "github.com/docker/cli/cli/command/stack/options" "github.com/docker/cli/cli/command/stack/swarm" "github.com/spf13/cobra" @@ -22,7 +21,7 @@ func newDeployCommand(dockerCLI command.Cli) *cobra.Command { if err := validateStackName(opts.Namespace); err != nil { return err } - config, err := loader.LoadComposefile(dockerCLI, opts) + config, err := loadComposeFile(dockerCLI, opts) if err != nil { return err } diff --git a/cli/command/stack/loader/loader.go b/cli/command/stack/loader.go similarity index 86% rename from cli/command/stack/loader/loader.go rename to cli/command/stack/loader.go index 8187efb5a6..3e42bf0283 100644 --- a/cli/command/stack/loader/loader.go +++ b/cli/command/stack/loader.go @@ -1,7 +1,7 @@ // FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: //go:build go1.23 -package loader +package stack import ( "fmt" @@ -21,11 +21,9 @@ import ( "github.com/pkg/errors" ) -// LoadComposefile parse the composefile specified in the cli and returns its Config and version. -// -// Deprecated: this function was for internal use and will be removed in the next release. -func LoadComposefile(dockerCli command.Cli, opts options.Deploy) (*composetypes.Config, error) { - configDetails, err := GetConfigDetails(opts.Composefiles, dockerCli.In()) +// loadComposeFile parse the composefile specified in the cli and returns its configOptions and version. +func loadComposeFile(streams command.Streams, opts options.Deploy) (*composetypes.Config, error) { + configDetails, err := getConfigDetails(opts.Composefiles, streams.In()) if err != nil { return nil, err } @@ -43,13 +41,13 @@ func LoadComposefile(dockerCli command.Cli, opts options.Deploy) (*composetypes. unsupportedProperties := loader.GetUnsupportedProperties(dicts...) if len(unsupportedProperties) > 0 { - _, _ = fmt.Fprintf(dockerCli.Err(), "Ignoring unsupported options: %s\n\n", + _, _ = fmt.Fprintf(streams.Err(), "Ignoring unsupported options: %s\n\n", strings.Join(unsupportedProperties, ", ")) } deprecatedProperties := loader.GetDeprecatedProperties(dicts...) if len(deprecatedProperties) > 0 { - _, _ = fmt.Fprintf(dockerCli.Err(), "Ignoring deprecated options:\n\n%s\n\n", + _, _ = fmt.Fprintf(streams.Err(), "Ignoring deprecated options:\n\n%s\n\n", propertyWarnings(deprecatedProperties)) } @@ -85,10 +83,8 @@ func propertyWarnings(properties map[string]string) string { return strings.Join(msgs, "\n\n") } -// GetConfigDetails parse the composefiles specified in the cli and returns their ConfigDetails -// -// Deprecated: this function was for internal use and will be removed in the next release. -func GetConfigDetails(composefiles []string, stdin io.Reader) (composetypes.ConfigDetails, error) { +// getConfigDetails parse the composefiles specified in the cli and returns their ConfigDetails +func getConfigDetails(composefiles []string, stdin io.Reader) (composetypes.ConfigDetails, error) { var details composetypes.ConfigDetails if len(composefiles) == 0 { diff --git a/cli/command/stack/loader/loader_test.go b/cli/command/stack/loader_test.go similarity index 92% rename from cli/command/stack/loader/loader_test.go rename to cli/command/stack/loader_test.go index 6c0da17aa3..8234400748 100644 --- a/cli/command/stack/loader/loader_test.go +++ b/cli/command/stack/loader_test.go @@ -1,4 +1,4 @@ -package loader +package stack import ( "os" @@ -22,7 +22,7 @@ services: file := fs.NewFile(t, "test-get-config-details", fs.WithContent(content)) defer file.Remove() - details, err := GetConfigDetails([]string{file.Path()}, nil) + details, err := getConfigDetails([]string{file.Path()}, nil) assert.NilError(t, err) assert.Check(t, is.Equal(filepath.Dir(file.Path()), details.WorkingDir)) assert.Assert(t, is.Len(details.ConfigFiles, 1)) @@ -37,7 +37,7 @@ services: foo: image: alpine:3.5 ` - details, err := GetConfigDetails([]string{"-"}, strings.NewReader(content)) + details, err := getConfigDetails([]string{"-"}, strings.NewReader(content)) assert.NilError(t, err) cwd, err := os.Getwd() assert.NilError(t, err) diff --git a/cli/command/stack/options/opts.go b/cli/command/stack/options/opts.go index 8d1fbd6e8b..9c7a86c95d 100644 --- a/cli/command/stack/options/opts.go +++ b/cli/command/stack/options/opts.go @@ -13,14 +13,6 @@ type Deploy struct { Quiet bool } -// Config holds docker stack config options -// -// Deprecated: this type was for internal use and will be removed in the next release. -type Config struct { - Composefiles []string - SkipInterpolation bool -} - // Remove holds docker stack remove options // // Deprecated: this type was for internal use and will be removed in the next release. From 26bb688ed0a860e0c65305c7a85be611d5880094 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Mon, 8 Sep 2025 15:56:52 +0200 Subject: [PATCH 2/2] cli/command/stack: internalize RunDeploy, RunRemove These were deprecated in ad6ab189a642625d54053ac6e7080e8940288d2d and were only used internally. Move them back inside the stack package. Signed-off-by: Sebastiaan van Stijn --- cli/command/stack/common.go | 30 +++ cli/command/stack/deploy.go | 110 +++++++- .../stack/{swarm => }/deploy_composefile.go | 51 ++-- .../{swarm => }/deploy_composefile_test.go | 2 +- cli/command/stack/deploy_test.go | 103 ++++++++ cli/command/stack/loader.go | 5 +- cli/command/stack/options/opts.go | 22 -- cli/command/stack/remove.go | 182 ++++++++++++- cli/command/stack/swarm/client_test.go | 241 ------------------ cli/command/stack/swarm/common.go | 37 --- cli/command/stack/swarm/deploy.go | 82 ------ cli/command/stack/swarm/deploy_test.go | 111 -------- cli/command/stack/swarm/remove.go | 172 ------------- 13 files changed, 433 insertions(+), 715 deletions(-) rename cli/command/stack/{swarm => }/deploy_composefile.go (85%) rename cli/command/stack/{swarm => }/deploy_composefile_test.go (99%) delete mode 100644 cli/command/stack/options/opts.go delete mode 100644 cli/command/stack/swarm/client_test.go delete mode 100644 cli/command/stack/swarm/common.go delete mode 100644 cli/command/stack/swarm/deploy.go delete mode 100644 cli/command/stack/swarm/deploy_test.go delete mode 100644 cli/command/stack/swarm/remove.go diff --git a/cli/command/stack/common.go b/cli/command/stack/common.go index cbf72486f4..63ea675d1b 100644 --- a/cli/command/stack/common.go +++ b/cli/command/stack/common.go @@ -1,6 +1,7 @@ package stack import ( + "context" "fmt" "strings" "unicode" @@ -8,6 +9,9 @@ import ( "github.com/docker/cli/cli/compose/convert" "github.com/docker/cli/opts" "github.com/moby/moby/api/types/filters" + "github.com/moby/moby/api/types/network" + "github.com/moby/moby/api/types/swarm" + "github.com/moby/moby/client" ) // validateStackName checks if the provided string is a valid stack name (namespace). @@ -34,6 +38,12 @@ func quotesOrWhitespace(r rune) bool { return unicode.IsSpace(r) || r == '"' || r == '\'' } +func getStackFilter(namespace string) filters.Args { + filter := filters.NewArgs() + filter.Add("label", convert.LabelNamespace+"="+namespace) + return filter +} + func getStackFilterFromOpt(namespace string, opt opts.FilterOpt) filters.Args { filter := opt.Value() filter.Add("label", convert.LabelNamespace+"="+namespace) @@ -45,3 +55,23 @@ func getAllStacksFilter() filters.Args { filter.Add("label", convert.LabelNamespace) return filter } + +func getStackServices(ctx context.Context, apiclient client.APIClient, namespace string) ([]swarm.Service, error) { + return apiclient.ServiceList(ctx, client.ServiceListOptions{Filters: getStackFilter(namespace)}) +} + +func getStackNetworks(ctx context.Context, apiclient client.APIClient, namespace string) ([]network.Summary, error) { + return apiclient.NetworkList(ctx, client.NetworkListOptions{Filters: getStackFilter(namespace)}) +} + +func getStackSecrets(ctx context.Context, apiclient client.APIClient, namespace string) ([]swarm.Secret, error) { + return apiclient.SecretList(ctx, client.SecretListOptions{Filters: getStackFilter(namespace)}) +} + +func getStackConfigs(ctx context.Context, apiclient client.APIClient, namespace string) ([]swarm.Config, error) { + return apiclient.ConfigList(ctx, client.ConfigListOptions{Filters: getStackFilter(namespace)}) +} + +func getStackTasks(ctx context.Context, apiclient client.APIClient, namespace string) ([]swarm.Task, error) { + return apiclient.TaskList(ctx, client.TaskListOptions{Filters: getStackFilter(namespace)}) +} diff --git a/cli/command/stack/deploy.go b/cli/command/stack/deploy.go index a2ac0b637e..72d6b13dc9 100644 --- a/cli/command/stack/deploy.go +++ b/cli/command/stack/deploy.go @@ -1,15 +1,33 @@ package stack import ( + "context" + "fmt" + "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" - "github.com/docker/cli/cli/command/stack/options" - "github.com/docker/cli/cli/command/stack/swarm" + "github.com/docker/cli/cli/compose/convert" + composetypes "github.com/docker/cli/cli/compose/types" + "github.com/moby/moby/api/types/swarm" + "github.com/moby/moby/api/types/versions" + "github.com/pkg/errors" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) +// deployOptions holds docker stack deploy options +type deployOptions struct { + composefiles []string + namespace string + resolveImage string + sendRegistryAuth bool + prune bool + detach bool + quiet bool +} + func newDeployCommand(dockerCLI command.Cli) *cobra.Command { - var opts options.Deploy + var opts deployOptions cmd := &cobra.Command{ Use: "deploy [OPTIONS] STACK", @@ -17,15 +35,15 @@ func newDeployCommand(dockerCLI command.Cli) *cobra.Command { Short: "Deploy a new stack or update an existing stack", Args: cli.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - opts.Namespace = args[0] - if err := validateStackName(opts.Namespace); err != nil { + opts.namespace = args[0] + if err := validateStackName(opts.namespace); err != nil { return err } config, err := loadComposeFile(dockerCLI, opts) if err != nil { return err } - return swarm.RunDeploy(cmd.Context(), dockerCLI, cmd.Flags(), &opts, config) + return 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) @@ -34,15 +52,81 @@ func newDeployCommand(dockerCLI command.Cli) *cobra.Command { } flags := cmd.Flags() - flags.StringSliceVarP(&opts.Composefiles, "compose-file", "c", []string{}, `Path to a Compose file, or "-" to read from stdin`) + flags.StringSliceVarP(&opts.composefiles, "compose-file", "c", []string{}, `Path to a Compose file, or "-" to read from stdin`) flags.SetAnnotation("compose-file", "version", []string{"1.25"}) - flags.BoolVar(&opts.SendRegistryAuth, "with-registry-auth", false, "Send registry authentication details to Swarm agents") - flags.BoolVar(&opts.Prune, "prune", false, "Prune services that are no longer referenced") + flags.BoolVar(&opts.sendRegistryAuth, "with-registry-auth", false, "Send registry authentication details to Swarm agents") + flags.BoolVar(&opts.prune, "prune", false, "Prune services that are no longer referenced") flags.SetAnnotation("prune", "version", []string{"1.27"}) - 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.StringVar(&opts.resolveImage, "resolve-image", resolveImageAlways, + `Query the registry to resolve image digest and supported platforms ("`+resolveImageAlways+`", "`+resolveImageChanged+`", "`+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") + 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 } + +// Resolve image constants +const ( + resolveImageAlways = "always" + resolveImageChanged = "changed" + resolveImageNever = "never" +) + +const defaultNetworkDriver = "overlay" + +// runDeploy is the swarm implementation of docker stack deploy +func runDeploy(ctx context.Context, dockerCLI command.Cli, flags *pflag.FlagSet, opts *deployOptions, cfg *composetypes.Config) error { + switch opts.resolveImage { + case resolveImageAlways, resolveImageChanged, resolveImageNever: + // valid options. + default: + return errors.Errorf("Invalid option %s for flag --resolve-image", opts.resolveImage) + } + + // client side image resolution should not be done when the supported + // server version is older than 1.30 + if versions.LessThan(dockerCLI.Client().ClientVersion(), "1.30") { + // TODO(thaJeztah): should this error if "opts.ResolveImage" is already other (unsupported) values? + 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) +} + +// checkDaemonIsSwarmManager does an Info API call to verify that the daemon is +// a swarm manager. This is necessary because we must create networks before we +// create services, but the API call for creating a network does not return a +// proper status code when it can't create a network in the "global" scope. +func checkDaemonIsSwarmManager(ctx context.Context, dockerCli command.Cli) error { + info, err := dockerCli.Client().Info(ctx) + if err != nil { + return err + } + if !info.Swarm.ControlAvailable { + return errors.New("this node is not a swarm manager. Use \"docker swarm init\" or \"docker swarm join\" to connect this node to swarm and try again") + } + return nil +} + +// pruneServices removes services that are no longer referenced in the source +func pruneServices(ctx context.Context, dockerCLI command.Cli, namespace convert.Namespace, services map[string]struct{}) { + apiClient := dockerCLI.Client() + + oldServices, err := getStackServices(ctx, apiClient, namespace.Name()) + if err != nil { + _, _ = fmt.Fprintln(dockerCLI.Err(), "Failed to list services:", err) + } + + toRemove := make([]swarm.Service, 0, len(oldServices)) + for _, service := range oldServices { + if _, exists := services[namespace.Descope(service.Spec.Name)]; !exists { + toRemove = append(toRemove, service) + } + } + removeServices(ctx, dockerCLI, toRemove) +} diff --git a/cli/command/stack/swarm/deploy_composefile.go b/cli/command/stack/deploy_composefile.go similarity index 85% rename from cli/command/stack/swarm/deploy_composefile.go rename to cli/command/stack/deploy_composefile.go index b18aeb7d51..8339cbcc06 100644 --- a/cli/command/stack/swarm/deploy_composefile.go +++ b/cli/command/stack/deploy_composefile.go @@ -1,4 +1,4 @@ -package swarm +package stack import ( "context" @@ -7,8 +7,7 @@ import ( "github.com/containerd/errdefs" "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/command/service" "github.com/docker/cli/cli/compose/convert" composetypes "github.com/docker/cli/cli/compose/types" "github.com/moby/moby/api/types/container" @@ -17,17 +16,17 @@ import ( "github.com/moby/moby/client" ) -func deployCompose(ctx context.Context, dockerCli command.Cli, opts *options.Deploy, config *composetypes.Config) error { +func deployCompose(ctx context.Context, dockerCli command.Cli, opts *deployOptions, config *composetypes.Config) error { if err := checkDaemonIsSwarmManager(ctx, dockerCli); err != nil { return err } - namespace := convert.NewNamespace(opts.Namespace) + namespace := convert.NewNamespace(opts.namespace) - if opts.Prune { + if opts.prune { services := map[string]struct{}{} - for _, service := range config.Services { - services[service.Name] = struct{}{} + for _, svc := range config.Services { + services[svc.Name] = struct{}{} } pruneServices(ctx, dockerCli, namespace, services) } @@ -62,16 +61,16 @@ func deployCompose(ctx context.Context, dockerCli command.Cli, opts *options.Dep return err } - serviceIDs, err := 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 { + if opts.detach { return nil } - return waitOnServices(ctx, dockerCli, serviceIDs, opts.Quiet) + return waitOnServices(ctx, dockerCli, serviceIDs, opts.quiet) } func getServicesDeclaredNetworks(serviceConfigs []composetypes.ServiceConfig) map[string]struct{} { @@ -196,8 +195,8 @@ func deployServices(ctx context.Context, dockerCLI command.Cli, services map[str } existingServiceMap := make(map[string]swarm.Service) - for _, service := range existingServices { - existingServiceMap[service.Spec.Name] = service + for _, svc := range existingServices { + existingServiceMap[svc.Spec.Name] = svc } var serviceIDs []string @@ -217,17 +216,17 @@ func deployServices(ctx context.Context, dockerCLI command.Cli, services map[str } } - if service, exists := existingServiceMap[name]; exists { - _, _ = fmt.Fprintf(out, "Updating service %s (id: %s)\n", name, service.ID) + if svc, exists := existingServiceMap[name]; exists { + _, _ = fmt.Fprintf(out, "Updating service %s (id: %s)\n", name, svc.ID) updateOpts := client.ServiceUpdateOptions{EncodedRegistryAuth: encodedAuth} switch resolveImage { - case ResolveImageAlways: + case resolveImageAlways: // image should be updated by the server using QueryRegistry updateOpts.QueryRegistry = true - case ResolveImageChanged: - if image != service.Spec.Labels[convert.LabelImage] { + case resolveImageChanged: + if image != svc.Spec.Labels[convert.LabelImage] { // Query the registry to resolve digest for the updated image updateOpts.QueryRegistry = true } else { @@ -235,24 +234,24 @@ func deployServices(ctx context.Context, dockerCLI command.Cli, services map[str // existing information that was set by QueryRegistry on the // previous deploy. Otherwise this will trigger an incorrect // service update. - serviceSpec.TaskTemplate.ContainerSpec.Image = service.Spec.TaskTemplate.ContainerSpec.Image + serviceSpec.TaskTemplate.ContainerSpec.Image = svc.Spec.TaskTemplate.ContainerSpec.Image } default: - if image == service.Spec.Labels[convert.LabelImage] { + if image == svc.Spec.Labels[convert.LabelImage] { // image has not changed; update the serviceSpec with the // existing information that was set by QueryRegistry on the // previous deploy. Otherwise this will trigger an incorrect // service update. - serviceSpec.TaskTemplate.ContainerSpec.Image = service.Spec.TaskTemplate.ContainerSpec.Image + serviceSpec.TaskTemplate.ContainerSpec.Image = svc.Spec.TaskTemplate.ContainerSpec.Image } } // Stack deploy does not have a `--force` option. Preserve existing // ForceUpdate value so that tasks are not re-deployed if not updated. // TODO move this to API client? - serviceSpec.TaskTemplate.ForceUpdate = service.Spec.TaskTemplate.ForceUpdate + serviceSpec.TaskTemplate.ForceUpdate = svc.Spec.TaskTemplate.ForceUpdate - response, err := apiClient.ServiceUpdate(ctx, service.ID, service.Version, serviceSpec, updateOpts) + response, err := apiClient.ServiceUpdate(ctx, svc.ID, svc.Version, serviceSpec, updateOpts) if err != nil { return nil, fmt.Errorf("failed to update service %s: %w", name, err) } @@ -261,12 +260,12 @@ func deployServices(ctx context.Context, dockerCLI command.Cli, services map[str _, _ = fmt.Fprintln(dockerCLI.Err(), warning) } - serviceIDs = append(serviceIDs, service.ID) + serviceIDs = append(serviceIDs, svc.ID) } else { _, _ = fmt.Fprintln(out, "Creating service", name) // query registry if flag disabling it was not set - queryRegistry := resolveImage == ResolveImageAlways || resolveImage == ResolveImageChanged + queryRegistry := resolveImage == resolveImageAlways || resolveImage == resolveImageChanged response, err := apiClient.ServiceCreate(ctx, serviceSpec, client.ServiceCreateOptions{ EncodedRegistryAuth: encodedAuth, @@ -286,7 +285,7 @@ func deployServices(ctx context.Context, dockerCLI command.Cli, services map[str 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 { + if err := service.WaitOnService(ctx, dockerCli, serviceID, quiet); err != nil { errs = append(errs, fmt.Errorf("%s: %w", serviceID, err)) } } diff --git a/cli/command/stack/swarm/deploy_composefile_test.go b/cli/command/stack/deploy_composefile_test.go similarity index 99% rename from cli/command/stack/swarm/deploy_composefile_test.go rename to cli/command/stack/deploy_composefile_test.go index 3a3c86006c..48a49ef4a3 100644 --- a/cli/command/stack/swarm/deploy_composefile_test.go +++ b/cli/command/stack/deploy_composefile_test.go @@ -1,4 +1,4 @@ -package swarm +package stack import ( "context" diff --git a/cli/command/stack/deploy_test.go b/cli/command/stack/deploy_test.go index df731256bd..36f12a956a 100644 --- a/cli/command/stack/deploy_test.go +++ b/cli/command/stack/deploy_test.go @@ -1,11 +1,16 @@ package stack import ( + "context" "io" "testing" + "github.com/docker/cli/cli/compose/convert" "github.com/docker/cli/internal/test" + "github.com/moby/moby/api/types/swarm" + "github.com/moby/moby/client" "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" ) func TestDeployWithEmptyName(t *testing.T) { @@ -16,3 +21,101 @@ func TestDeployWithEmptyName(t *testing.T) { assert.ErrorContains(t, cmd.Execute(), `invalid stack name: "' '"`) } + +func TestPruneServices(t *testing.T) { + ctx := context.Background() + namespace := convert.NewNamespace("foo") + services := map[string]struct{}{ + "new": {}, + "keep": {}, + } + apiClient := &fakeClient{services: []string{objectName("foo", "keep"), objectName("foo", "remove")}} + dockerCli := test.NewFakeCli(apiClient) + + pruneServices(ctx, dockerCli, namespace, services) + assert.Check(t, is.DeepEqual(buildObjectIDs([]string{objectName("foo", "remove")}), apiClient.removedServices)) +} + +// TestServiceUpdateResolveImageChanged tests that the service's +// image digest, and "ForceUpdate" is preserved if the image did not change in +// the compose file +func TestServiceUpdateResolveImageChanged(t *testing.T) { + namespace := convert.NewNamespace("mystack") + + var ( + receivedOptions client.ServiceUpdateOptions + receivedService swarm.ServiceSpec + ) + + fakeCli := test.NewFakeCli(&fakeClient{ + serviceListFunc: func(options client.ServiceListOptions) ([]swarm.Service, error) { + return []swarm.Service{ + { + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{ + Name: namespace.Name() + "_myservice", + Labels: map[string]string{"com.docker.stack.image": "foobar:1.2.3"}, + }, + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Image: "foobar:1.2.3@sha256:deadbeef", + }, + ForceUpdate: 123, + }, + }, + }, + }, nil + }, + serviceUpdateFunc: func(serviceID string, version swarm.Version, service swarm.ServiceSpec, options client.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) { + receivedOptions = options + receivedService = service + return swarm.ServiceUpdateResponse{}, nil + }, + }) + + testcases := []struct { + image string + expectedQueryRegistry bool + expectedImage string + expectedForceUpdate uint64 + }{ + // Image not changed + { + image: "foobar:1.2.3", + expectedQueryRegistry: false, + expectedImage: "foobar:1.2.3@sha256:deadbeef", + expectedForceUpdate: 123, + }, + // Image changed + { + image: "foobar:1.2.4", + expectedQueryRegistry: true, + expectedImage: "foobar:1.2.4", + expectedForceUpdate: 123, + }, + } + + ctx := context.Background() + + for _, tc := range testcases { + t.Run(tc.image, func(t *testing.T) { + spec := map[string]swarm.ServiceSpec{ + "myservice": { + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Image: tc.image, + }, + }, + }, + } + _, err := deployServices(ctx, fakeCli, 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)) + assert.Check(t, is.Equal(receivedService.TaskTemplate.ForceUpdate, tc.expectedForceUpdate)) + + receivedService = swarm.ServiceSpec{} + receivedOptions = client.ServiceUpdateOptions{} + }) + } +} diff --git a/cli/command/stack/loader.go b/cli/command/stack/loader.go index 3e42bf0283..8ff33f1606 100644 --- a/cli/command/stack/loader.go +++ b/cli/command/stack/loader.go @@ -14,7 +14,6 @@ import ( "github.com/distribution/reference" "github.com/docker/cli/cli/command" - "github.com/docker/cli/cli/command/stack/options" "github.com/docker/cli/cli/compose/loader" "github.com/docker/cli/cli/compose/schema" composetypes "github.com/docker/cli/cli/compose/types" @@ -22,8 +21,8 @@ import ( ) // loadComposeFile parse the composefile specified in the cli and returns its configOptions and version. -func loadComposeFile(streams command.Streams, opts options.Deploy) (*composetypes.Config, error) { - configDetails, err := getConfigDetails(opts.Composefiles, streams.In()) +func loadComposeFile(streams command.Streams, opts deployOptions) (*composetypes.Config, error) { + configDetails, err := getConfigDetails(opts.composefiles, streams.In()) if err != nil { return nil, err } diff --git a/cli/command/stack/options/opts.go b/cli/command/stack/options/opts.go deleted file mode 100644 index 9c7a86c95d..0000000000 --- a/cli/command/stack/options/opts.go +++ /dev/null @@ -1,22 +0,0 @@ -package options - -// Deploy holds docker stack deploy options -// -// Deprecated: this type was for internal use and will be removed in the next release. -type Deploy struct { - Composefiles []string - Namespace string - ResolveImage string - SendRegistryAuth bool - Prune bool - Detach bool - Quiet bool -} - -// Remove holds docker stack remove options -// -// Deprecated: this type was for internal use and will be removed in the next release. -type Remove struct { - Namespaces []string - Detach bool -} diff --git a/cli/command/stack/remove.go b/cli/command/stack/remove.go index d38df7cf49..380d423914 100644 --- a/cli/command/stack/remove.go +++ b/cli/command/stack/remove.go @@ -1,15 +1,28 @@ package stack import ( + "context" + "errors" + "fmt" + "sort" + "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" - "github.com/docker/cli/cli/command/stack/options" - "github.com/docker/cli/cli/command/stack/swarm" + "github.com/moby/moby/api/types/network" + "github.com/moby/moby/api/types/swarm" + "github.com/moby/moby/api/types/versions" + "github.com/moby/moby/client" "github.com/spf13/cobra" ) +// removeOptions holds docker stack remove options +type removeOptions struct { + namespaces []string + detach bool +} + func newRemoveCommand(dockerCLI command.Cli) *cobra.Command { - var opts options.Remove + var opts removeOptions cmd := &cobra.Command{ Use: "rm [OPTIONS] STACK [STACK...]", @@ -17,11 +30,11 @@ func newRemoveCommand(dockerCLI command.Cli) *cobra.Command { Short: "Remove one or more stacks", Args: cli.RequiresMinArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - opts.Namespaces = args - if err := validateStackNames(opts.Namespaces); err != nil { + opts.namespaces = args + if err := validateStackNames(opts.namespaces); err != nil { return err } - return swarm.RunRemove(cmd.Context(), dockerCLI, opts) + return runRemove(cmd.Context(), dockerCLI, opts) }, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return completeNames(dockerCLI)(cmd, args, toComplete) @@ -30,6 +43,161 @@ func newRemoveCommand(dockerCLI command.Cli) *cobra.Command { } flags := cmd.Flags() - flags.BoolVarP(&opts.Detach, "detach", "d", true, "Do not wait for stack removal") + flags.BoolVarP(&opts.detach, "detach", "d", true, "Do not wait for stack removal") return cmd } + +// runRemove is the swarm implementation of docker stack remove. +func runRemove(ctx context.Context, dockerCli command.Cli, opts removeOptions) error { + apiClient := dockerCli.Client() + + var errs []error + for _, namespace := range opts.namespaces { + services, err := getStackServices(ctx, apiClient, namespace) + if err != nil { + return err + } + + networks, err := getStackNetworks(ctx, apiClient, namespace) + if err != nil { + return err + } + + var secrets []swarm.Secret + if versions.GreaterThanOrEqualTo(apiClient.ClientVersion(), "1.25") { + secrets, err = getStackSecrets(ctx, apiClient, namespace) + if err != nil { + return err + } + } + + var configs []swarm.Config + if versions.GreaterThanOrEqualTo(apiClient.ClientVersion(), "1.30") { + configs, err = getStackConfigs(ctx, apiClient, namespace) + if err != nil { + return err + } + } + + if len(services)+len(networks)+len(secrets)+len(configs) == 0 { + _, _ = fmt.Fprintln(dockerCli.Err(), "Nothing found in stack:", namespace) + continue + } + + // TODO(thaJeztah): change this "hasError" boolean to return a (multi-)error for each of these functions instead. + hasError := removeServices(ctx, dockerCli, services) + hasError = removeSecrets(ctx, dockerCli, secrets) || hasError + hasError = removeConfigs(ctx, dockerCli, configs) || hasError + hasError = removeNetworks(ctx, dockerCli, networks) || hasError + + if hasError { + errs = append(errs, errors.New("failed to remove some resources from stack: "+namespace)) + continue + } + + if !opts.detach { + err = waitOnTasks(ctx, apiClient, namespace) + if err != nil { + errs = append(errs, fmt.Errorf("failed to wait on tasks of stack: %s: %w", namespace, err)) + } + } + } + return errors.Join(errs...) +} + +func sortServiceByName(services []swarm.Service) func(i, j int) bool { + return func(i, j int) bool { + return services[i].Spec.Name < services[j].Spec.Name + } +} + +func removeServices(ctx context.Context, dockerCLI command.Cli, services []swarm.Service) bool { + var hasError bool + sort.Slice(services, sortServiceByName(services)) + for _, service := range services { + _, _ = fmt.Fprintln(dockerCLI.Out(), "Removing service", service.Spec.Name) + if err := dockerCLI.Client().ServiceRemove(ctx, service.ID); err != nil { + hasError = true + _, _ = fmt.Fprintf(dockerCLI.Err(), "Failed to remove service %s: %s", service.ID, err) + } + } + return hasError +} + +func removeNetworks(ctx context.Context, dockerCLI command.Cli, networks []network.Summary) bool { + var hasError bool + for _, nw := range networks { + _, _ = fmt.Fprintln(dockerCLI.Out(), "Removing network", nw.Name) + if err := dockerCLI.Client().NetworkRemove(ctx, nw.ID); err != nil { + hasError = true + _, _ = fmt.Fprintf(dockerCLI.Err(), "Failed to remove network %s: %s", nw.ID, err) + } + } + return hasError +} + +func removeSecrets(ctx context.Context, dockerCli command.Cli, secrets []swarm.Secret) bool { + var hasError bool + for _, secret := range secrets { + _, _ = fmt.Fprintln(dockerCli.Out(), "Removing secret", secret.Spec.Name) + if err := dockerCli.Client().SecretRemove(ctx, secret.ID); err != nil { + hasError = true + _, _ = fmt.Fprintf(dockerCli.Err(), "Failed to remove secret %s: %s", secret.ID, err) + } + } + return hasError +} + +func removeConfigs(ctx context.Context, dockerCLI command.Cli, configs []swarm.Config) bool { + var hasError bool + for _, config := range configs { + _, _ = fmt.Fprintln(dockerCLI.Out(), "Removing config", config.Spec.Name) + if err := dockerCLI.Client().ConfigRemove(ctx, config.ID); err != nil { + hasError = true + _, _ = fmt.Fprintf(dockerCLI.Err(), "Failed to remove config %s: %s", config.ID, err) + } + } + return hasError +} + +var numberedStates = map[swarm.TaskState]int64{ + swarm.TaskStateNew: 1, + swarm.TaskStateAllocated: 2, + swarm.TaskStatePending: 3, + swarm.TaskStateAssigned: 4, + swarm.TaskStateAccepted: 5, + swarm.TaskStatePreparing: 6, + swarm.TaskStateReady: 7, + swarm.TaskStateStarting: 8, + swarm.TaskStateRunning: 9, + swarm.TaskStateComplete: 10, + swarm.TaskStateShutdown: 11, + swarm.TaskStateFailed: 12, + swarm.TaskStateRejected: 13, +} + +func terminalState(state swarm.TaskState) bool { + return numberedStates[state] > numberedStates[swarm.TaskStateRunning] +} + +func waitOnTasks(ctx context.Context, apiClient client.APIClient, namespace string) error { + terminalStatesReached := 0 + for { + tasks, err := getStackTasks(ctx, apiClient, namespace) + if err != nil { + return fmt.Errorf("failed to get tasks: %w", err) + } + + for _, task := range tasks { + if terminalState(task.Status.State) { + terminalStatesReached++ + break + } + } + + if terminalStatesReached == len(tasks) { + break + } + } + return nil +} diff --git a/cli/command/stack/swarm/client_test.go b/cli/command/stack/swarm/client_test.go deleted file mode 100644 index fa50d32551..0000000000 --- a/cli/command/stack/swarm/client_test.go +++ /dev/null @@ -1,241 +0,0 @@ -package swarm - -import ( - "context" - "strings" - - "github.com/docker/cli/cli/compose/convert" - "github.com/moby/moby/api/types" - "github.com/moby/moby/api/types/filters" - "github.com/moby/moby/api/types/network" - "github.com/moby/moby/api/types/swarm" - "github.com/moby/moby/client" -) - -type fakeClient struct { - client.Client - - version string - - services []string - networks []string - secrets []string - configs []string - - removedServices []string - removedNetworks []string - removedSecrets []string - removedConfigs []string - - serviceListFunc func(options client.ServiceListOptions) ([]swarm.Service, error) - networkListFunc func(options client.NetworkListOptions) ([]network.Summary, error) - secretListFunc func(options client.SecretListOptions) ([]swarm.Secret, error) - configListFunc func(options client.ConfigListOptions) ([]swarm.Config, error) - nodeListFunc func(options client.NodeListOptions) ([]swarm.Node, error) - taskListFunc func(options client.TaskListOptions) ([]swarm.Task, error) - nodeInspectWithRaw func(ref string) (swarm.Node, []byte, error) - - serviceUpdateFunc func(serviceID string, version swarm.Version, service swarm.ServiceSpec, options client.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) - - serviceRemoveFunc func(serviceID string) error - networkRemoveFunc func(networkID string) error - secretRemoveFunc func(secretID string) error - configRemoveFunc func(configID string) error -} - -func (*fakeClient) ServerVersion(context.Context) (types.Version, error) { - return types.Version{ - Version: "docker-dev", - APIVersion: client.MaxAPIVersion, - }, nil -} - -func (cli *fakeClient) ClientVersion() string { - return cli.version -} - -func (cli *fakeClient) ServiceList(_ context.Context, options client.ServiceListOptions) ([]swarm.Service, error) { - if cli.serviceListFunc != nil { - return cli.serviceListFunc(options) - } - - namespace := namespaceFromFilters(options.Filters) - servicesList := []swarm.Service{} - for _, name := range cli.services { - if belongToNamespace(name, namespace) { - servicesList = append(servicesList, serviceFromName(name)) - } - } - return servicesList, nil -} - -func (cli *fakeClient) NetworkList(_ context.Context, options client.NetworkListOptions) ([]network.Summary, error) { - if cli.networkListFunc != nil { - return cli.networkListFunc(options) - } - - namespace := namespaceFromFilters(options.Filters) - networksList := []network.Summary{} - for _, name := range cli.networks { - if belongToNamespace(name, namespace) { - networksList = append(networksList, networkFromName(name)) - } - } - return networksList, nil -} - -func (cli *fakeClient) SecretList(_ context.Context, options client.SecretListOptions) ([]swarm.Secret, error) { - if cli.secretListFunc != nil { - return cli.secretListFunc(options) - } - - namespace := namespaceFromFilters(options.Filters) - secretsList := []swarm.Secret{} - for _, name := range cli.secrets { - if belongToNamespace(name, namespace) { - secretsList = append(secretsList, secretFromName(name)) - } - } - return secretsList, nil -} - -func (cli *fakeClient) ConfigList(_ context.Context, options client.ConfigListOptions) ([]swarm.Config, error) { - if cli.configListFunc != nil { - return cli.configListFunc(options) - } - - namespace := namespaceFromFilters(options.Filters) - configsList := []swarm.Config{} - for _, name := range cli.configs { - if belongToNamespace(name, namespace) { - configsList = append(configsList, configFromName(name)) - } - } - return configsList, nil -} - -func (cli *fakeClient) TaskList(_ context.Context, options client.TaskListOptions) ([]swarm.Task, error) { - if cli.taskListFunc != nil { - return cli.taskListFunc(options) - } - return []swarm.Task{}, nil -} - -func (cli *fakeClient) NodeList(_ context.Context, options client.NodeListOptions) ([]swarm.Node, error) { - if cli.nodeListFunc != nil { - return cli.nodeListFunc(options) - } - return []swarm.Node{}, nil -} - -func (cli *fakeClient) NodeInspectWithRaw(_ context.Context, ref string) (swarm.Node, []byte, error) { - if cli.nodeInspectWithRaw != nil { - return cli.nodeInspectWithRaw(ref) - } - return swarm.Node{}, nil, nil -} - -func (cli *fakeClient) ServiceUpdate(_ context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, options client.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) { - if cli.serviceUpdateFunc != nil { - return cli.serviceUpdateFunc(serviceID, version, service, options) - } - - return swarm.ServiceUpdateResponse{}, nil -} - -func (cli *fakeClient) ServiceRemove(_ context.Context, serviceID string) error { - if cli.serviceRemoveFunc != nil { - return cli.serviceRemoveFunc(serviceID) - } - - cli.removedServices = append(cli.removedServices, serviceID) - return nil -} - -func (cli *fakeClient) NetworkRemove(_ context.Context, networkID string) error { - if cli.networkRemoveFunc != nil { - return cli.networkRemoveFunc(networkID) - } - - cli.removedNetworks = append(cli.removedNetworks, networkID) - return nil -} - -func (cli *fakeClient) SecretRemove(_ context.Context, secretID string) error { - if cli.secretRemoveFunc != nil { - return cli.secretRemoveFunc(secretID) - } - - cli.removedSecrets = append(cli.removedSecrets, secretID) - return nil -} - -func (cli *fakeClient) ConfigRemove(_ context.Context, configID string) error { - if cli.configRemoveFunc != nil { - return cli.configRemoveFunc(configID) - } - - cli.removedConfigs = append(cli.removedConfigs, configID) - return nil -} - -func serviceFromName(name string) swarm.Service { - return swarm.Service{ - ID: "ID-" + name, - Spec: swarm.ServiceSpec{ - Annotations: swarm.Annotations{Name: name}, - }, - } -} - -func networkFromName(name string) network.Summary { - return network.Summary{ - Network: network.Network{ - ID: "ID-" + name, - Name: name, - }, - } -} - -func secretFromName(name string) swarm.Secret { - return swarm.Secret{ - ID: "ID-" + name, - Spec: swarm.SecretSpec{ - Annotations: swarm.Annotations{Name: name}, - }, - } -} - -func configFromName(name string) swarm.Config { - return swarm.Config{ - ID: "ID-" + name, - Spec: swarm.ConfigSpec{ - Annotations: swarm.Annotations{Name: name}, - }, - } -} - -func namespaceFromFilters(fltrs filters.Args) string { - label := fltrs.Get("label")[0] - return strings.TrimPrefix(label, convert.LabelNamespace+"=") -} - -func belongToNamespace(id, namespace string) bool { - return strings.HasPrefix(id, namespace+"_") -} - -func objectName(namespace, name string) string { - return namespace + "_" + name -} - -func objectID(name string) string { - return "ID-" + name -} - -func buildObjectIDs(objectNames []string) []string { - IDs := make([]string, len(objectNames)) - for i, name := range objectNames { - IDs[i] = objectID(name) - } - return IDs -} diff --git a/cli/command/stack/swarm/common.go b/cli/command/stack/swarm/common.go deleted file mode 100644 index 691508efa3..0000000000 --- a/cli/command/stack/swarm/common.go +++ /dev/null @@ -1,37 +0,0 @@ -package swarm - -import ( - "context" - - "github.com/docker/cli/cli/compose/convert" - "github.com/moby/moby/api/types/filters" - "github.com/moby/moby/api/types/network" - "github.com/moby/moby/api/types/swarm" - "github.com/moby/moby/client" -) - -func getStackFilter(namespace string) filters.Args { - filter := filters.NewArgs() - filter.Add("label", convert.LabelNamespace+"="+namespace) - return filter -} - -func getStackServices(ctx context.Context, apiclient client.APIClient, namespace string) ([]swarm.Service, error) { - return apiclient.ServiceList(ctx, client.ServiceListOptions{Filters: getStackFilter(namespace)}) -} - -func getStackNetworks(ctx context.Context, apiclient client.APIClient, namespace string) ([]network.Summary, error) { - return apiclient.NetworkList(ctx, client.NetworkListOptions{Filters: getStackFilter(namespace)}) -} - -func getStackSecrets(ctx context.Context, apiclient client.APIClient, namespace string) ([]swarm.Secret, error) { - return apiclient.SecretList(ctx, client.SecretListOptions{Filters: getStackFilter(namespace)}) -} - -func getStackConfigs(ctx context.Context, apiclient client.APIClient, namespace string) ([]swarm.Config, error) { - return apiclient.ConfigList(ctx, client.ConfigListOptions{Filters: getStackFilter(namespace)}) -} - -func getStackTasks(ctx context.Context, apiclient client.APIClient, namespace string) ([]swarm.Task, error) { - return apiclient.TaskList(ctx, client.TaskListOptions{Filters: getStackFilter(namespace)}) -} diff --git a/cli/command/stack/swarm/deploy.go b/cli/command/stack/swarm/deploy.go deleted file mode 100644 index 3528023a2b..0000000000 --- a/cli/command/stack/swarm/deploy.go +++ /dev/null @@ -1,82 +0,0 @@ -package swarm - -import ( - "context" - "fmt" - - "github.com/docker/cli/cli/command" - "github.com/docker/cli/cli/command/stack/options" - "github.com/docker/cli/cli/compose/convert" - composetypes "github.com/docker/cli/cli/compose/types" - "github.com/moby/moby/api/types/swarm" - "github.com/moby/moby/api/types/versions" - "github.com/pkg/errors" - "github.com/spf13/pflag" -) - -// Resolve image constants -const ( - defaultNetworkDriver = "overlay" - ResolveImageAlways = "always" - ResolveImageChanged = "changed" - ResolveImageNever = "never" -) - -// RunDeploy is the swarm implementation of docker stack deploy -// -// Deprecated: this function was for internal use and will be removed in the next release. -func RunDeploy(ctx context.Context, dockerCLI command.Cli, flags *pflag.FlagSet, opts *options.Deploy, cfg *composetypes.Config) error { - switch opts.ResolveImage { - case ResolveImageAlways, ResolveImageChanged, ResolveImageNever: - // valid options. - default: - return errors.Errorf("Invalid option %s for flag --resolve-image", opts.ResolveImage) - } - - // client side image resolution should not be done when the supported - // server version is older than 1.30 - if versions.LessThan(dockerCLI.Client().ClientVersion(), "1.30") { - // TODO(thaJeztah): should this error if "opts.ResolveImage" is already other (unsupported) values? - 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) -} - -// checkDaemonIsSwarmManager does an Info API call to verify that the daemon is -// a swarm manager. This is necessary because we must create networks before we -// create services, but the API call for creating a network does not return a -// proper status code when it can't create a network in the "global" scope. -func checkDaemonIsSwarmManager(ctx context.Context, dockerCli command.Cli) error { - info, err := dockerCli.Client().Info(ctx) - if err != nil { - return err - } - if !info.Swarm.ControlAvailable { - return errors.New("this node is not a swarm manager. Use \"docker swarm init\" or \"docker swarm join\" to connect this node to swarm and try again") - } - return nil -} - -// pruneServices removes services that are no longer referenced in the source -func pruneServices(ctx context.Context, dockerCLI command.Cli, namespace convert.Namespace, services map[string]struct{}) { - apiClient := dockerCLI.Client() - - oldServices, err := getStackServices(ctx, apiClient, namespace.Name()) - if err != nil { - _, _ = fmt.Fprintln(dockerCLI.Err(), "Failed to list services:", err) - } - - toRemove := make([]swarm.Service, 0, len(oldServices)) - for _, service := range oldServices { - if _, exists := services[namespace.Descope(service.Spec.Name)]; !exists { - toRemove = append(toRemove, service) - } - } - removeServices(ctx, dockerCLI, toRemove) -} diff --git a/cli/command/stack/swarm/deploy_test.go b/cli/command/stack/swarm/deploy_test.go deleted file mode 100644 index 2ac4f5da3d..0000000000 --- a/cli/command/stack/swarm/deploy_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package swarm - -import ( - "context" - "testing" - - "github.com/docker/cli/cli/compose/convert" - "github.com/docker/cli/internal/test" - "github.com/moby/moby/api/types/swarm" - "github.com/moby/moby/client" - "gotest.tools/v3/assert" - is "gotest.tools/v3/assert/cmp" -) - -func TestPruneServices(t *testing.T) { - ctx := context.Background() - namespace := convert.NewNamespace("foo") - services := map[string]struct{}{ - "new": {}, - "keep": {}, - } - apiClient := &fakeClient{services: []string{objectName("foo", "keep"), objectName("foo", "remove")}} - dockerCli := test.NewFakeCli(apiClient) - - pruneServices(ctx, dockerCli, namespace, services) - assert.Check(t, is.DeepEqual(buildObjectIDs([]string{objectName("foo", "remove")}), apiClient.removedServices)) -} - -// TestServiceUpdateResolveImageChanged tests that the service's -// image digest, and "ForceUpdate" is preserved if the image did not change in -// the compose file -func TestServiceUpdateResolveImageChanged(t *testing.T) { - namespace := convert.NewNamespace("mystack") - - var ( - receivedOptions client.ServiceUpdateOptions - receivedService swarm.ServiceSpec - ) - - fakeCli := test.NewFakeCli(&fakeClient{ - serviceListFunc: func(options client.ServiceListOptions) ([]swarm.Service, error) { - return []swarm.Service{ - { - Spec: swarm.ServiceSpec{ - Annotations: swarm.Annotations{ - Name: namespace.Name() + "_myservice", - Labels: map[string]string{"com.docker.stack.image": "foobar:1.2.3"}, - }, - TaskTemplate: swarm.TaskSpec{ - ContainerSpec: &swarm.ContainerSpec{ - Image: "foobar:1.2.3@sha256:deadbeef", - }, - ForceUpdate: 123, - }, - }, - }, - }, nil - }, - serviceUpdateFunc: func(serviceID string, version swarm.Version, service swarm.ServiceSpec, options client.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) { - receivedOptions = options - receivedService = service - return swarm.ServiceUpdateResponse{}, nil - }, - }) - - testcases := []struct { - image string - expectedQueryRegistry bool - expectedImage string - expectedForceUpdate uint64 - }{ - // Image not changed - { - image: "foobar:1.2.3", - expectedQueryRegistry: false, - expectedImage: "foobar:1.2.3@sha256:deadbeef", - expectedForceUpdate: 123, - }, - // Image changed - { - image: "foobar:1.2.4", - expectedQueryRegistry: true, - expectedImage: "foobar:1.2.4", - expectedForceUpdate: 123, - }, - } - - ctx := context.Background() - - for _, tc := range testcases { - t.Run(tc.image, func(t *testing.T) { - spec := map[string]swarm.ServiceSpec{ - "myservice": { - TaskTemplate: swarm.TaskSpec{ - ContainerSpec: &swarm.ContainerSpec{ - Image: tc.image, - }, - }, - }, - } - _, err := deployServices(ctx, fakeCli, 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)) - assert.Check(t, is.Equal(receivedService.TaskTemplate.ForceUpdate, tc.expectedForceUpdate)) - - receivedService = swarm.ServiceSpec{} - receivedOptions = client.ServiceUpdateOptions{} - }) - } -} diff --git a/cli/command/stack/swarm/remove.go b/cli/command/stack/swarm/remove.go deleted file mode 100644 index a945f9a57c..0000000000 --- a/cli/command/stack/swarm/remove.go +++ /dev/null @@ -1,172 +0,0 @@ -package swarm - -import ( - "context" - "errors" - "fmt" - "sort" - - "github.com/docker/cli/cli/command" - "github.com/docker/cli/cli/command/stack/options" - "github.com/moby/moby/api/types/network" - "github.com/moby/moby/api/types/swarm" - "github.com/moby/moby/api/types/versions" - "github.com/moby/moby/client" -) - -// RunRemove is the swarm implementation of docker stack remove -// -// Deprecated: this function was for internal use and will be removed in the next release. -func RunRemove(ctx context.Context, dockerCli command.Cli, opts options.Remove) error { - apiClient := dockerCli.Client() - - var errs []error - for _, namespace := range opts.Namespaces { - services, err := getStackServices(ctx, apiClient, namespace) - if err != nil { - return err - } - - networks, err := getStackNetworks(ctx, apiClient, namespace) - if err != nil { - return err - } - - var secrets []swarm.Secret - if versions.GreaterThanOrEqualTo(apiClient.ClientVersion(), "1.25") { - secrets, err = getStackSecrets(ctx, apiClient, namespace) - if err != nil { - return err - } - } - - var configs []swarm.Config - if versions.GreaterThanOrEqualTo(apiClient.ClientVersion(), "1.30") { - configs, err = getStackConfigs(ctx, apiClient, namespace) - if err != nil { - return err - } - } - - if len(services)+len(networks)+len(secrets)+len(configs) == 0 { - _, _ = fmt.Fprintln(dockerCli.Err(), "Nothing found in stack:", namespace) - continue - } - - // TODO(thaJeztah): change this "hasError" boolean to return a (multi-)error for each of these functions instead. - hasError := removeServices(ctx, dockerCli, services) - hasError = removeSecrets(ctx, dockerCli, secrets) || hasError - hasError = removeConfigs(ctx, dockerCli, configs) || hasError - hasError = removeNetworks(ctx, dockerCli, networks) || hasError - - if hasError { - errs = append(errs, errors.New("failed to remove some resources from stack: "+namespace)) - continue - } - - if !opts.Detach { - err = waitOnTasks(ctx, apiClient, namespace) - if err != nil { - errs = append(errs, fmt.Errorf("failed to wait on tasks of stack: %s: %w", namespace, err)) - } - } - } - return errors.Join(errs...) -} - -func sortServiceByName(services []swarm.Service) func(i, j int) bool { - return func(i, j int) bool { - return services[i].Spec.Name < services[j].Spec.Name - } -} - -func removeServices(ctx context.Context, dockerCLI command.Cli, services []swarm.Service) bool { - var hasError bool - sort.Slice(services, sortServiceByName(services)) - for _, service := range services { - _, _ = fmt.Fprintln(dockerCLI.Out(), "Removing service", service.Spec.Name) - if err := dockerCLI.Client().ServiceRemove(ctx, service.ID); err != nil { - hasError = true - _, _ = fmt.Fprintf(dockerCLI.Err(), "Failed to remove service %s: %s", service.ID, err) - } - } - return hasError -} - -func removeNetworks(ctx context.Context, dockerCLI command.Cli, networks []network.Summary) bool { - var hasError bool - for _, nw := range networks { - _, _ = fmt.Fprintln(dockerCLI.Out(), "Removing network", nw.Name) - if err := dockerCLI.Client().NetworkRemove(ctx, nw.ID); err != nil { - hasError = true - _, _ = fmt.Fprintf(dockerCLI.Err(), "Failed to remove network %s: %s", nw.ID, err) - } - } - return hasError -} - -func removeSecrets(ctx context.Context, dockerCli command.Cli, secrets []swarm.Secret) bool { - var hasError bool - for _, secret := range secrets { - _, _ = fmt.Fprintln(dockerCli.Out(), "Removing secret", secret.Spec.Name) - if err := dockerCli.Client().SecretRemove(ctx, secret.ID); err != nil { - hasError = true - _, _ = fmt.Fprintf(dockerCli.Err(), "Failed to remove secret %s: %s", secret.ID, err) - } - } - return hasError -} - -func removeConfigs(ctx context.Context, dockerCLI command.Cli, configs []swarm.Config) bool { - var hasError bool - for _, config := range configs { - _, _ = fmt.Fprintln(dockerCLI.Out(), "Removing config", config.Spec.Name) - if err := dockerCLI.Client().ConfigRemove(ctx, config.ID); err != nil { - hasError = true - _, _ = fmt.Fprintf(dockerCLI.Err(), "Failed to remove config %s: %s", config.ID, err) - } - } - return hasError -} - -var numberedStates = map[swarm.TaskState]int64{ - swarm.TaskStateNew: 1, - swarm.TaskStateAllocated: 2, - swarm.TaskStatePending: 3, - swarm.TaskStateAssigned: 4, - swarm.TaskStateAccepted: 5, - swarm.TaskStatePreparing: 6, - swarm.TaskStateReady: 7, - swarm.TaskStateStarting: 8, - swarm.TaskStateRunning: 9, - swarm.TaskStateComplete: 10, - swarm.TaskStateShutdown: 11, - swarm.TaskStateFailed: 12, - swarm.TaskStateRejected: 13, -} - -func terminalState(state swarm.TaskState) bool { - return numberedStates[state] > numberedStates[swarm.TaskStateRunning] -} - -func waitOnTasks(ctx context.Context, apiClient client.APIClient, namespace string) error { - terminalStatesReached := 0 - for { - tasks, err := getStackTasks(ctx, apiClient, namespace) - if err != nil { - return fmt.Errorf("failed to get tasks: %w", err) - } - - for _, task := range tasks { - if terminalState(task.Status.State) { - terminalStatesReached++ - break - } - } - - if terminalStatesReached == len(tasks) { - break - } - } - return nil -}