diff --git a/command/stack/deploy.go b/command/stack/deploy.go index 22557fc45b..46af5f63b1 100644 --- a/command/stack/deploy.go +++ b/command/stack/deploy.go @@ -3,8 +3,10 @@ package stack import ( "fmt" + "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/cli" "github.com/docker/docker/cli/command" + "github.com/docker/docker/cli/compose/convert" "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/net/context" @@ -19,6 +21,7 @@ type deployOptions struct { composefile string namespace string sendRegistryAuth bool + prune bool } func newDeployCommand(dockerCli *command.DockerCli) *cobra.Command { @@ -39,6 +42,8 @@ func newDeployCommand(dockerCli *command.DockerCli) *cobra.Command { addBundlefileFlag(&opts.bundlefile, flags) addComposefileFlag(&opts.composefile, flags) addRegistryAuthFlag(&opts.sendRegistryAuth, flags) + flags.BoolVar(&opts.prune, "prune", false, "Prune services that are no longer referenced") + flags.SetAnnotation("prune", "version", []string{"1.27"}) return cmd } @@ -71,3 +76,22 @@ func checkDaemonIsSwarmManager(ctx context.Context, dockerCli *command.DockerCli } 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{}) bool { + client := dockerCli.Client() + + oldServices, err := getServices(ctx, client, namespace.Name()) + if err != nil { + fmt.Fprintf(dockerCli.Err(), "Failed to list services: %s", err) + return true + } + + pruneServices := []swarm.Service{} + for _, service := range oldServices { + if _, exists := services[namespace.Descope(service.Spec.Name)]; !exists { + pruneServices = append(pruneServices, service) + } + } + return removeServices(ctx, dockerCli, pruneServices) +} diff --git a/command/stack/deploy_bundlefile.go b/command/stack/deploy_bundlefile.go index 5a178c4ab6..14e627cafc 100644 --- a/command/stack/deploy_bundlefile.go +++ b/command/stack/deploy_bundlefile.go @@ -21,6 +21,14 @@ func deployBundle(ctx context.Context, dockerCli *command.DockerCli, opts deploy namespace := convert.NewNamespace(opts.namespace) + if opts.prune { + services := map[string]struct{}{} + for service := range bundle.Services { + services[service] = struct{}{} + } + pruneServices(ctx, dockerCli, namespace, services) + } + networks := make(map[string]types.NetworkCreate) for _, service := range bundle.Services { for _, networkName := range service.Networks { diff --git a/command/stack/deploy_composefile.go b/command/stack/deploy_composefile.go index 3e62494325..f8951e06ee 100644 --- a/command/stack/deploy_composefile.go +++ b/command/stack/deploy_composefile.go @@ -52,8 +52,15 @@ func deployCompose(ctx context.Context, dockerCli *command.DockerCli, opts deplo namespace := convert.NewNamespace(opts.namespace) - serviceNetworks := getServicesDeclaredNetworks(config.Services) + if opts.prune { + services := map[string]struct{}{} + for _, service := range config.Services { + services[service.Name] = struct{}{} + } + pruneServices(ctx, dockerCli, namespace, services) + } + serviceNetworks := getServicesDeclaredNetworks(config.Services) networks, externalNetworks := convert.Networks(namespace, config.Networks, serviceNetworks) if err := validateExternalNetworks(ctx, dockerCli, externalNetworks); err != nil { return err diff --git a/command/stack/deploy_test.go b/command/stack/deploy_test.go new file mode 100644 index 0000000000..dac1350547 --- /dev/null +++ b/command/stack/deploy_test.go @@ -0,0 +1,54 @@ +package stack + +import ( + "bytes" + "testing" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/cli/compose/convert" + "github.com/docker/docker/cli/internal/test" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/testutil/assert" + "golang.org/x/net/context" +) + +type fakeClient struct { + client.Client + serviceList []string + removedIDs []string +} + +func (cli *fakeClient) ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) { + services := []swarm.Service{} + for _, name := range cli.serviceList { + services = append(services, swarm.Service{ + ID: name, + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{Name: name}, + }, + }) + } + return services, nil +} + +func (cli *fakeClient) ServiceRemove(ctx context.Context, serviceID string) error { + cli.removedIDs = append(cli.removedIDs, serviceID) + return nil +} + +func TestPruneServices(t *testing.T) { + ctx := context.Background() + namespace := convert.NewNamespace("foo") + services := map[string]struct{}{ + "new": {}, + "keep": {}, + } + client := &fakeClient{serviceList: []string{"foo_keep", "foo_remove"}} + dockerCli := test.NewFakeCli(client, &bytes.Buffer{}) + dockerCli.SetErr(&bytes.Buffer{}) + + pruneServices(ctx, dockerCli, namespace, services) + + assert.DeepEqual(t, client.removedIDs, []string{"foo_remove"}) +} diff --git a/command/stack/remove.go b/command/stack/remove.go index 966c1aa6bf..d466caf2b4 100644 --- a/command/stack/remove.go +++ b/command/stack/remove.go @@ -68,7 +68,7 @@ func runRemove(dockerCli *command.DockerCli, opts removeOptions) error { func removeServices( ctx context.Context, - dockerCli *command.DockerCli, + dockerCli command.Cli, services []swarm.Service, ) bool { var err error @@ -83,7 +83,7 @@ func removeServices( func removeNetworks( ctx context.Context, - dockerCli *command.DockerCli, + dockerCli command.Cli, networks []types.NetworkResource, ) bool { var err error @@ -98,7 +98,7 @@ func removeNetworks( func removeSecrets( ctx context.Context, - dockerCli *command.DockerCli, + dockerCli command.Cli, secrets []swarm.Secret, ) bool { var err error diff --git a/compose/convert/compose.go b/compose/convert/compose.go index a4571df02f..d7208bfc5d 100644 --- a/compose/convert/compose.go +++ b/compose/convert/compose.go @@ -2,6 +2,7 @@ package convert import ( "io/ioutil" + "strings" "github.com/docker/docker/api/types" networktypes "github.com/docker/docker/api/types/network" @@ -24,6 +25,11 @@ func (n Namespace) Scope(name string) string { return n.name + "_" + name } +// Descope returns the name without the namespace prefix +func (n Namespace) Descope(name string) string { + return strings.TrimPrefix(name, n.name+"_") +} + // Name returns the name of the namespace func (n Namespace) Name() string { return n.name diff --git a/internal/test/cli.go b/internal/test/cli.go index 72de42586d..610918a651 100644 --- a/internal/test/cli.go +++ b/internal/test/cli.go @@ -35,7 +35,7 @@ func (c *FakeCli) SetIn(in io.ReadCloser) { c.in = in } -// SetErr sets the standard error stream th cli should write on +// SetErr sets the stderr stream for the cli to the specified io.Writer func (c *FakeCli) SetErr(err io.Writer) { c.err = err }