Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions cli/command/stack/common.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package stack

import (
"context"
"fmt"
"strings"
"unicode"

"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).
Expand All @@ -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)
Expand All @@ -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)})
}
18 changes: 11 additions & 7 deletions cli/command/stack/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}

Expand Down
113 changes: 98 additions & 15 deletions cli/command/stack/deploy.go
Original file line number Diff line number Diff line change
@@ -1,32 +1,49 @@
package stack

import (
"context"
"fmt"

"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/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",
Aliases: []string{"up"},
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 := loader.LoadComposefile(dockerCLI, opts)
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)
Expand All @@ -35,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)
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package swarm
package stack

import (
"context"
Expand All @@ -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"
Expand All @@ -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)
}
Expand Down Expand Up @@ -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{} {
Expand Down Expand Up @@ -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
Expand All @@ -217,42 +216,42 @@ 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 {
// 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
}
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)
}
Expand All @@ -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,
Expand All @@ -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))
}
}
Expand Down
Loading
Loading