diff --git a/aci/backend.go b/aci/backend.go index f2198b54..0aeffb09 100644 --- a/aci/backend.go +++ b/aci/backend.go @@ -28,6 +28,7 @@ import ( "github.com/docker/compose-cli/aci/login" "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/containers" + "github.com/docker/compose-cli/api/resources" "github.com/docker/compose-cli/api/secrets" "github.com/docker/compose-cli/api/volumes" "github.com/docker/compose-cli/backend" @@ -96,6 +97,9 @@ func getAciAPIService(aciCtx store.AciContext) *aciAPIService { aciVolumeService: &aciVolumeService{ aciContext: aciCtx, }, + aciResourceService: &aciResourceService{ + aciContext: aciCtx, + }, } } @@ -103,6 +107,7 @@ type aciAPIService struct { *aciContainerService *aciComposeService *aciVolumeService + *aciResourceService } func (a *aciAPIService) ContainerService() containers.Service { @@ -123,6 +128,10 @@ func (a *aciAPIService) VolumeService() volumes.Service { return a.aciVolumeService } +func (a *aciAPIService) ResourceService() resources.Service { + return a.aciResourceService +} + func getContainerID(group containerinstance.ContainerGroup, container containerinstance.Container) string { containerID := *group.Name + composeContainerSeparator + *container.Name if _, ok := group.Tags[singleContainerTag]; ok { diff --git a/aci/convert/convert.go b/aci/convert/convert.go index 28388c4c..f67168b9 100644 --- a/aci/convert/convert.go +++ b/aci/convert/convert.go @@ -542,12 +542,17 @@ func ContainerGroupToContainer(containerID string, cg containerinstance.Containe // GetStatus returns status for the specified container func GetStatus(container containerinstance.Container, group containerinstance.ContainerGroup) string { - status := compose.UNKNOWN - if group.InstanceView != nil && group.InstanceView.State != nil { - status = "Node " + *group.InstanceView.State - } + status := GetGroupStatus(group) if container.InstanceView != nil && container.InstanceView.CurrentState != nil { status = *container.InstanceView.CurrentState.State } return status } + +// GetGroupStatus returns status for the container group +func GetGroupStatus(group containerinstance.ContainerGroup) string { + if group.InstanceView != nil && group.InstanceView.State != nil { + return "Node " + *group.InstanceView.State + } + return compose.UNKNOWN +} diff --git a/aci/resources.go b/aci/resources.go new file mode 100644 index 00000000..e1beff20 --- /dev/null +++ b/aci/resources.go @@ -0,0 +1,54 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package aci + +import ( + "context" + + "github.com/hashicorp/go-multierror" + + "github.com/docker/compose-cli/aci/convert" + "github.com/docker/compose-cli/api/resources" + "github.com/docker/compose-cli/context/store" +) + +type aciResourceService struct { + aciContext store.AciContext +} + +func (cs *aciResourceService) Prune(ctx context.Context, request resources.PruneRequest) ([]string, error) { + res, err := getACIContainerGroups(ctx, cs.aciContext.SubscriptionID, cs.aciContext.ResourceGroup) + if err != nil { + return nil, err + } + multierr := &multierror.Error{} + deleted := []string{} + for _, containerGroup := range res { + if !request.Force && convert.GetGroupStatus(containerGroup) == "Node "+convert.StatusRunning { + continue + } + + if !request.DryRun { + _, err := deleteACIContainerGroup(ctx, cs.aciContext, *containerGroup.Name) + multierr = multierror.Append(multierr, err) + } + if err == nil { + deleted = append(deleted, *containerGroup.Name) + } + } + return deleted, multierr.ErrorOrNil() +} diff --git a/api/client/client.go b/api/client/client.go index 9dfb2f62..ffcdad19 100644 --- a/api/client/client.go +++ b/api/client/client.go @@ -21,6 +21,7 @@ import ( "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/containers" + "github.com/docker/compose-cli/api/resources" "github.com/docker/compose-cli/api/secrets" "github.com/docker/compose-cli/api/volumes" "github.com/docker/compose-cli/backend" @@ -107,3 +108,12 @@ func (c *Client) VolumeService() volumes.Service { return &volumeService{} } + +// ResourceService returns the backend service for the current context +func (c *Client) ResourceService() resources.Service { + if vs := c.bs.ResourceService(); vs != nil { + return vs + } + + return &resourceService{} +} diff --git a/api/client/resources.go b/api/client/resources.go new file mode 100644 index 00000000..74eb0873 --- /dev/null +++ b/api/client/resources.go @@ -0,0 +1,32 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package client + +import ( + "context" + + "github.com/docker/compose-cli/api/resources" + "github.com/docker/compose-cli/errdefs" +) + +type resourceService struct { +} + +// Prune prune resources +func (c *resourceService) Prune(ctx context.Context, request resources.PruneRequest) ([]string, error) { + return nil, errdefs.ErrNotImplemented +} diff --git a/api/resources/api.go b/api/resources/api.go new file mode 100644 index 00000000..f3f6f184 --- /dev/null +++ b/api/resources/api.go @@ -0,0 +1,33 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package resources + +import ( + "context" +) + +// PruneRequest options on what to prune +type PruneRequest struct { + Force bool + DryRun bool +} + +// Service interacts with the underlying container backend +type Service interface { + // Prune prune resources + Prune(ctx context.Context, request PruneRequest) ([]string, error) +} diff --git a/backend/backend.go b/backend/backend.go index f42df393..8852151b 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -25,6 +25,7 @@ import ( "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/containers" + "github.com/docker/compose-cli/api/resources" "github.com/docker/compose-cli/api/secrets" "github.com/docker/compose-cli/api/volumes" "github.com/docker/compose-cli/context/cloud" @@ -55,6 +56,7 @@ var backends = struct { type Service interface { ContainerService() containers.Service ComposeService() compose.Service + ResourceService() resources.Service SecretsService() secrets.Service VolumeService() volumes.Service } diff --git a/cli/cmd/prune.go b/cli/cmd/prune.go new file mode 100644 index 00000000..b0a61b37 --- /dev/null +++ b/cli/cmd/prune.go @@ -0,0 +1,69 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package cmd + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/docker/compose-cli/api/client" + "github.com/docker/compose-cli/api/resources" +) + +type pruneOpts struct { + force bool + dryRun bool +} + +// PruneCommand deletes backend resources +func PruneCommand() *cobra.Command { + var opts pruneOpts + cmd := &cobra.Command{ + Use: "prune", + Short: "prune existing resources in current context", + Args: cobra.MaximumNArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + return runPrune(cmd.Context(), opts) + }, + } + + cmd.Flags().BoolVar(&opts.force, "force", false, "Also prune running containers and Compose applications") + cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "List resources to be deleted, but do not delete them") + + return cmd +} + +func runPrune(ctx context.Context, opts pruneOpts) error { + c, err := client.New(ctx) + if err != nil { + return errors.Wrap(err, "cannot connect to backend") + } + + ids, err := c.ResourceService().Prune(ctx, resources.PruneRequest{Force: opts.force, DryRun: opts.dryRun}) + if opts.dryRun { + fmt.Println("resources that would be deleted:") + } else { + fmt.Println("deleted resources:") + } + for _, id := range ids { + fmt.Println(id) + } + return err +} diff --git a/cli/main.go b/cli/main.go index 2de093e1..7b70d887 100644 --- a/cli/main.go +++ b/cli/main.go @@ -122,6 +122,7 @@ func main() { cmd.StopCommand(), cmd.KillCommand(), cmd.SecretCommand(), + cmd.PruneCommand(), // Place holders cmd.EcsCommand(), diff --git a/ecs/backend.go b/ecs/backend.go index 16d1c35d..ab5b3dde 100644 --- a/ecs/backend.go +++ b/ecs/backend.go @@ -24,6 +24,7 @@ import ( "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/containers" + "github.com/docker/compose-cli/api/resources" "github.com/docker/compose-cli/api/secrets" "github.com/docker/compose-cli/api/volumes" "github.com/docker/compose-cli/backend" @@ -103,6 +104,10 @@ func (a *ecsAPIService) VolumeService() volumes.Service { return nil } +func (a *ecsAPIService) ResourceService() resources.Service { + return nil +} + func getCloudService() (cloud.Service, error) { return ecsCloudService{}, nil } diff --git a/ecs/local/backend.go b/ecs/local/backend.go index f2a78962..934bf0b8 100644 --- a/ecs/local/backend.go +++ b/ecs/local/backend.go @@ -23,6 +23,7 @@ import ( "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/containers" + "github.com/docker/compose-cli/api/resources" "github.com/docker/compose-cli/api/secrets" "github.com/docker/compose-cli/api/volumes" "github.com/docker/compose-cli/backend" @@ -70,3 +71,7 @@ func (e ecsLocalSimulation) SecretsService() secrets.Service { func (e ecsLocalSimulation) ComposeService() compose.Service { return e } + +func (e ecsLocalSimulation) ResourceService() resources.Service { + return nil +} diff --git a/example/backend.go b/example/backend.go index 9f68bdbf..8e8f1ede 100644 --- a/example/backend.go +++ b/example/backend.go @@ -28,6 +28,7 @@ import ( "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/containers" + "github.com/docker/compose-cli/api/resources" "github.com/docker/compose-cli/api/secrets" "github.com/docker/compose-cli/api/volumes" "github.com/docker/compose-cli/backend" @@ -56,6 +57,10 @@ func (a *apiService) VolumeService() volumes.Service { return nil } +func (a *apiService) ResourceService() resources.Service { + return nil +} + func init() { backend.Register("example", "example", service, cloud.NotImplementedCloudService) } @@ -68,9 +73,9 @@ type containerService struct{} func (cs *containerService) Inspect(ctx context.Context, id string) (containers.Container, error) { return containers.Container{ - ID: "id", - Image: "nginx", - Platform: "Linux", + ID: "id", + Image: "nginx", + Platform: "Linux", HostConfig: &containers.HostConfig{ RestartPolicy: "none", }, diff --git a/local/backend.go b/local/backend.go index c616715b..baaf7e4f 100644 --- a/local/backend.go +++ b/local/backend.go @@ -38,6 +38,7 @@ import ( "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/containers" + "github.com/docker/compose-cli/api/resources" "github.com/docker/compose-cli/api/secrets" "github.com/docker/compose-cli/api/volumes" "github.com/docker/compose-cli/backend" @@ -80,6 +81,10 @@ func (ms *local) VolumeService() volumes.Service { return nil } +func (ms *local) ResourceService() resources.Service { + return nil +} + func (ms *local) Inspect(ctx context.Context, id string) (containers.Container, error) { c, err := ms.apiClient.ContainerInspect(ctx, id) if err != nil { diff --git a/server/metrics_test.go b/server/metrics_test.go index 659634c3..9c275230 100644 --- a/server/metrics_test.go +++ b/server/metrics_test.go @@ -21,6 +21,8 @@ import ( "strings" "testing" + "github.com/docker/compose-cli/api/resources" + "github.com/stretchr/testify/mock" "google.golang.org/grpc" "google.golang.org/grpc/metadata" @@ -116,6 +118,7 @@ func (noopService) ContainerService() containers.Service { return nil } func (noopService) ComposeService() compose.Service { return nil } func (noopService) SecretsService() secrets.Service { return nil } func (noopService) VolumeService() volumes.Service { return nil } +func (noopService) ResourceService() resources.Service { return nil } type mockMetricsClient struct { mock.Mock diff --git a/tests/aci-e2e/e2e-aci_test.go b/tests/aci-e2e/e2e-aci_test.go index 198cba62..ce806070 100644 --- a/tests/aci-e2e/e2e-aci_test.go +++ b/tests/aci-e2e/e2e-aci_test.go @@ -488,13 +488,27 @@ func TestContainerRunAttached(t *testing.T) { waitForStatus(t, c, container, convert.StatusRunning) }) - t.Run("kill & rm stopped container", func(t *testing.T) { - res := c.RunDockerCmd("kill", container) - res.Assert(t, icmd.Expected{Out: container}) - waitForStatus(t, c, container, "Terminated", "Node Stopped") + t.Run("prune dry run", func(t *testing.T) { + res := c.RunDockerCmd("prune", "--dry-run") + fmt.Println("prune output:") + assert.Equal(t, "resources that would be deleted:\n", res.Stdout()) + res = c.RunDockerCmd("prune", "--dry-run", "--force") + assert.Equal(t, "resources that would be deleted:\n"+container+"\n", res.Stdout()) + }) - res = c.RunDockerCmd("rm", container) - res.Assert(t, icmd.Expected{Out: container}) + t.Run("prune", func(t *testing.T) { + res := c.RunDockerCmd("prune") + assert.Equal(t, "deleted resources:\n", res.Stdout()) + res = c.RunDockerCmd("ps") + l := lines(res.Stdout()) + assert.Equal(t, 2, len(l)) + + res = c.RunDockerCmd("prune", "--force") + assert.Equal(t, "deleted resources:\n"+container+"\n", res.Stdout()) + + res = c.RunDockerCmd("ps", "--all") + l = lines(res.Stdout()) + assert.Equal(t, 1, len(l)) }) }