diff --git a/api/containers/api.go b/api/containers/api.go index c3770c02..876b3268 100644 --- a/api/containers/api.go +++ b/api/containers/api.go @@ -85,7 +85,7 @@ type Port struct { type ContainerConfig struct { // ID uniquely identifies the container ID string - // Image specifies the iamge reference used for a container + // Image specifies the image reference used for a container Image string // Command are the arguments passed to the container's entrypoint Command []string diff --git a/local/backend.go b/local/backend.go index ce94af92..134fddb0 100644 --- a/local/backend.go +++ b/local/backend.go @@ -19,20 +19,9 @@ package local import ( - "bufio" "context" - "io" - "strconv" - "strings" - "time" - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" - "github.com/docker/docker/pkg/stdcopy" - "github.com/docker/docker/pkg/stringid" - "github.com/docker/go-connections/nat" - "github.com/pkg/errors" "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/containers" @@ -41,11 +30,11 @@ import ( "github.com/docker/compose-cli/api/volumes" "github.com/docker/compose-cli/backend" "github.com/docker/compose-cli/context/cloud" - "github.com/docker/compose-cli/errdefs" ) type local struct { - apiClient *client.Client + *containerService + *volumeService } func init() { @@ -59,12 +48,13 @@ func service(ctx context.Context) (backend.Service, error) { } return &local{ - apiClient, + containerService: &containerService{apiClient}, + volumeService: &volumeService{apiClient}, }, nil } -func (ms *local) ContainerService() containers.Service { - return ms +func (cs *containerService) ContainerService() containers.Service { + return cs } func (ms *local) ComposeService() compose.Service { @@ -75,252 +65,10 @@ func (ms *local) SecretsService() secrets.Service { return nil } -func (ms *local) VolumeService() volumes.Service { - return nil +func (vs *volumeService) VolumeService() volumes.Service { + return vs } 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 { - return containers.Container{}, err - } - - status := "" - if c.State != nil { - status = c.State.Status - } - - command := "" - if c.Config != nil && - c.Config.Cmd != nil { - command = strings.Join(c.Config.Cmd, " ") - } - - return containers.Container{ - ID: stringid.TruncateID(c.ID), - Status: status, - Image: c.Image, - Command: command, - Platform: c.Platform, - }, nil -} - -func (ms *local) List(ctx context.Context, all bool) ([]containers.Container, error) { - css, err := ms.apiClient.ContainerList(ctx, types.ContainerListOptions{ - All: all, - }) - - if err != nil { - return []containers.Container{}, err - } - - var result []containers.Container - for _, container := range css { - result = append(result, containers.Container{ - ID: stringid.TruncateID(container.ID), - Image: container.Image, - // TODO: `Status` is a human readable string ("Up 24 minutes"), - // we need to return the `State` instead but first we need to - // define an enum on the proto side with all the possible container - // statuses. We also need to add a `Created` property on the gRPC side. - Status: container.Status, - Command: container.Command, - Ports: toPorts(container.Ports), - }) - } - - return result, nil -} - -func (ms *local) Run(ctx context.Context, r containers.ContainerConfig) error { - exposedPorts, hostBindings, err := fromPorts(r.Ports) - if err != nil { - return err - } - - containerConfig := &container.Config{ - Image: r.Image, - Labels: r.Labels, - ExposedPorts: exposedPorts, - } - hostConfig := &container.HostConfig{ - PortBindings: hostBindings, - AutoRemove: r.AutoRemove, - } - - created, err := ms.apiClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, r.ID) - - if err != nil { - if client.IsErrNotFound(err) { - io, err := ms.apiClient.ImagePull(ctx, r.Image, types.ImagePullOptions{}) - if err != nil { - return err - } - scanner := bufio.NewScanner(io) - - // Read the whole body, otherwise the pulling stops - for scanner.Scan() { - } - - if err = scanner.Err(); err != nil { - return err - } - if err = io.Close(); err != nil { - return err - } - created, err = ms.apiClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, r.ID) - if err != nil { - return err - } - } else { - return err - } - } - - return ms.apiClient.ContainerStart(ctx, created.ID, types.ContainerStartOptions{}) -} - -func (ms *local) Start(ctx context.Context, containerID string) error { - return ms.apiClient.ContainerStart(ctx, containerID, types.ContainerStartOptions{}) -} - -func (ms *local) Stop(ctx context.Context, containerID string, timeout *uint32) error { - var t *time.Duration - if timeout != nil { - timeoutValue := time.Duration(*timeout) * time.Second - t = &timeoutValue - } - return ms.apiClient.ContainerStop(ctx, containerID, t) -} - -func (ms *local) Kill(ctx context.Context, containerID string, signal string) error { - return ms.apiClient.ContainerKill(ctx, containerID, signal) -} - -func (ms *local) Exec(ctx context.Context, name string, request containers.ExecRequest) error { - cec, err := ms.apiClient.ContainerExecCreate(ctx, name, types.ExecConfig{ - Cmd: []string{request.Command}, - Tty: true, - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - }) - if err != nil { - return err - } - resp, err := ms.apiClient.ContainerExecAttach(ctx, cec.ID, types.ExecStartCheck{ - Tty: true, - }) - if err != nil { - return err - } - defer resp.Close() - - readChannel := make(chan error, 10) - writeChannel := make(chan error, 10) - - go func() { - _, err := io.Copy(request.Stdout, resp.Reader) - readChannel <- err - }() - - go func() { - _, err := io.Copy(resp.Conn, request.Stdin) - writeChannel <- err - }() - - for { - select { - case err := <-readChannel: - return err - case err := <-writeChannel: - return err - } - } -} - -func (ms *local) Logs(ctx context.Context, containerName string, request containers.LogsRequest) error { - c, err := ms.apiClient.ContainerInspect(ctx, containerName) - if err != nil { - return err - } - - r, err := ms.apiClient.ContainerLogs(ctx, containerName, types.ContainerLogsOptions{ - ShowStdout: true, - ShowStderr: true, - Follow: request.Follow, - }) - - if err != nil { - return err - } - - // nolint errcheck - defer r.Close() - - if c.Config.Tty { - _, err = io.Copy(request.Writer, r) - } else { - _, err = stdcopy.StdCopy(request.Writer, request.Writer, r) - } - - return err -} - -func (ms *local) Delete(ctx context.Context, containerID string, request containers.DeleteRequest) error { - err := ms.apiClient.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{ - Force: request.Force, - }) - if client.IsErrNotFound(err) { - return errors.Wrapf(errdefs.ErrNotFound, "container %q", containerID) - } - return err -} - -func toPorts(ports []types.Port) []containers.Port { - result := []containers.Port{} - for _, port := range ports { - result = append(result, containers.Port{ - ContainerPort: uint32(port.PrivatePort), - HostPort: uint32(port.PublicPort), - HostIP: port.IP, - Protocol: port.Type, - }) - } - - return result -} - -func fromPorts(ports []containers.Port) (map[nat.Port]struct{}, map[nat.Port][]nat.PortBinding, error) { - var ( - exposedPorts = make(map[nat.Port]struct{}, len(ports)) - bindings = make(map[nat.Port][]nat.PortBinding) - ) - - for _, port := range ports { - p, err := nat.NewPort(port.Protocol, strconv.Itoa(int(port.ContainerPort))) - if err != nil { - return nil, nil, err - } - - if _, exists := exposedPorts[p]; !exists { - exposedPorts[p] = struct{}{} - } - - portBinding := nat.PortBinding{ - HostIP: port.HostIP, - HostPort: strconv.Itoa(int(port.HostPort)), - } - bslice, exists := bindings[p] - if !exists { - bslice = []nat.PortBinding{} - } - bindings[p] = append(bslice, portBinding) - } - - return exposedPorts, bindings, nil -} diff --git a/local/containers.go b/local/containers.go new file mode 100644 index 00000000..327a991e --- /dev/null +++ b/local/containers.go @@ -0,0 +1,264 @@ +// +build local + +/* + 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 local + +import ( + "bufio" + "context" + "io" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" + "github.com/docker/docker/pkg/stringid" + "github.com/pkg/errors" + + "github.com/docker/compose-cli/api/containers" + "github.com/docker/compose-cli/errdefs" +) + +type containerService struct { + apiClient *client.Client +} + +func (cs *containerService) Inspect(ctx context.Context, id string) (containers.Container, error) { + c, err := cs.apiClient.ContainerInspect(ctx, id) + if err != nil { + return containers.Container{}, err + } + + status := "" + if c.State != nil { + status = c.State.Status + } + + command := "" + if c.Config != nil && + c.Config.Cmd != nil { + command = strings.Join(c.Config.Cmd, " ") + } + + rc := toRuntimeConfig(&c) + hc := toHostConfig(&c) + + return containers.Container{ + ID: stringid.TruncateID(c.ID), + Status: status, + Image: c.Image, + Command: command, + Platform: c.Platform, + Config: rc, + HostConfig: hc, + }, nil +} + +func (cs *containerService) List(ctx context.Context, all bool) ([]containers.Container, error) { + css, err := cs.apiClient.ContainerList(ctx, types.ContainerListOptions{ + All: all, + }) + + if err != nil { + return []containers.Container{}, err + } + + var result []containers.Container + for _, container := range css { + result = append(result, containers.Container{ + ID: stringid.TruncateID(container.ID), + Image: container.Image, + // TODO: `Status` is a human readable string ("Up 24 minutes"), + // we need to return the `State` instead but first we need to + // define an enum on the proto side with all the possible container + // statuses. We also need to add a `Created` property on the gRPC side. + Status: container.Status, + Command: container.Command, + Ports: toPorts(container.Ports), + }) + } + + return result, nil +} + +func (cs *containerService) Run(ctx context.Context, r containers.ContainerConfig) error { + exposedPorts, hostBindings, err := fromPorts(r.Ports) + if err != nil { + return err + } + + var mounts []mount.Mount + for _, v := range r.Volumes { + tokens := strings.Split(v, ":") + if len(tokens) != 2 { + return errors.Wrapf(errdefs.ErrParsingFailed, "volume %q has invalid format", v) + } + src := tokens[0] + tgt := tokens[1] + mounts = append(mounts, mount.Mount{Type: "volume", Source: src, Target: tgt}) + } + + containerConfig := &container.Config{ + Image: r.Image, + Cmd: r.Command, + Labels: r.Labels, + Env: r.Environment, + ExposedPorts: exposedPorts, + } + hostConfig := &container.HostConfig{ + PortBindings: hostBindings, + Mounts: mounts, + AutoRemove: r.AutoRemove, + RestartPolicy: toRestartPolicy(r.RestartPolicyCondition), + Resources: container.Resources{ + NanoCPUs: int64(r.CPULimit * 1e9), + Memory: int64(r.MemLimit), + }, + } + + created, err := cs.apiClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, r.ID) + + if err != nil { + if client.IsErrNotFound(err) { + io, err := cs.apiClient.ImagePull(ctx, r.Image, types.ImagePullOptions{}) + if err != nil { + return err + } + scanner := bufio.NewScanner(io) + + // Read the whole body, otherwise the pulling stops + for scanner.Scan() { + } + + if err = scanner.Err(); err != nil { + return err + } + if err = io.Close(); err != nil { + return err + } + created, err = cs.apiClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, r.ID) + if err != nil { + return err + } + } else { + return err + } + } + + return cs.apiClient.ContainerStart(ctx, created.ID, types.ContainerStartOptions{}) +} + +func (cs *containerService) Start(ctx context.Context, containerID string) error { + return cs.apiClient.ContainerStart(ctx, containerID, types.ContainerStartOptions{}) +} + +func (cs *containerService) Stop(ctx context.Context, containerID string, timeout *uint32) error { + var t *time.Duration + if timeout != nil { + timeoutValue := time.Duration(*timeout) * time.Second + t = &timeoutValue + } + return cs.apiClient.ContainerStop(ctx, containerID, t) +} + +func (cs *containerService) Kill(ctx context.Context, containerID string, signal string) error { + return cs.apiClient.ContainerKill(ctx, containerID, signal) +} + +func (cs *containerService) Exec(ctx context.Context, name string, request containers.ExecRequest) error { + cec, err := cs.apiClient.ContainerExecCreate(ctx, name, types.ExecConfig{ + Cmd: []string{request.Command}, + Tty: true, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + }) + if err != nil { + return err + } + resp, err := cs.apiClient.ContainerExecAttach(ctx, cec.ID, types.ExecStartCheck{ + Tty: true, + }) + if err != nil { + return err + } + defer resp.Close() + + readChannel := make(chan error, 10) + writeChannel := make(chan error, 10) + + go func() { + _, err := io.Copy(request.Stdout, resp.Reader) + readChannel <- err + }() + + go func() { + _, err := io.Copy(resp.Conn, request.Stdin) + writeChannel <- err + }() + + for { + select { + case err := <-readChannel: + return err + case err := <-writeChannel: + return err + } + } +} + +func (cs *containerService) Logs(ctx context.Context, containerName string, request containers.LogsRequest) error { + c, err := cs.apiClient.ContainerInspect(ctx, containerName) + if err != nil { + return err + } + + r, err := cs.apiClient.ContainerLogs(ctx, containerName, types.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: request.Follow, + }) + + if err != nil { + return err + } + + // nolint errcheck + defer r.Close() + + if c.Config.Tty { + _, err = io.Copy(request.Writer, r) + } else { + _, err = stdcopy.StdCopy(request.Writer, request.Writer, r) + } + + return err +} + +func (cs *containerService) Delete(ctx context.Context, containerID string, request containers.DeleteRequest) error { + err := cs.apiClient.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{ + Force: request.Force, + }) + if client.IsErrNotFound(err) { + return errors.Wrapf(errdefs.ErrNotFound, "container %q", containerID) + } + return err +} diff --git a/local/convert.go b/local/convert.go new file mode 100644 index 00000000..05931a36 --- /dev/null +++ b/local/convert.go @@ -0,0 +1,150 @@ +// +build local + +/* + 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 local + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/go-connections/nat" + + "github.com/docker/compose-cli/api/containers" +) + +func toRuntimeConfig(m *types.ContainerJSON) *containers.RuntimeConfig { + if m.Config == nil { + return nil + } + var env map[string]string + if m.Config.Env != nil { + env = make(map[string]string) + for _, e := range m.Config.Env { + tokens := strings.Split(e, "=") + if len(tokens) != 2 { + continue + } + env[tokens[0]] = tokens[1] + } + } + + var labels []string + if m.Config.Labels != nil { + for k, v := range m.Config.Labels { + labels = append(labels, fmt.Sprintf("%s=%s", k, v)) + } + } + sort.Strings(labels) + + if env == nil && + labels == nil { + return nil + } + + return &containers.RuntimeConfig{ + Env: env, + Labels: labels, + } +} + +func toHostConfig(m *types.ContainerJSON) *containers.HostConfig { + if m.HostConfig == nil { + return nil + } + + return &containers.HostConfig{ + AutoRemove: m.HostConfig.AutoRemove, + RestartPolicy: fromRestartPolicyName(m.HostConfig.RestartPolicy.Name), + CPULimit: float64(m.HostConfig.Resources.NanoCPUs) / 1e9, + MemoryLimit: uint64(m.HostConfig.Resources.Memory), + } +} + +func toPorts(ports []types.Port) []containers.Port { + result := []containers.Port{} + for _, port := range ports { + result = append(result, containers.Port{ + ContainerPort: uint32(port.PrivatePort), + HostPort: uint32(port.PublicPort), + HostIP: port.IP, + Protocol: port.Type, + }) + } + + return result +} + +func fromPorts(ports []containers.Port) (map[nat.Port]struct{}, map[nat.Port][]nat.PortBinding, error) { + var ( + exposedPorts = make(map[nat.Port]struct{}, len(ports)) + bindings = make(map[nat.Port][]nat.PortBinding) + ) + + for _, port := range ports { + p, err := nat.NewPort(port.Protocol, strconv.Itoa(int(port.ContainerPort))) + if err != nil { + return nil, nil, err + } + + if _, exists := exposedPorts[p]; !exists { + exposedPorts[p] = struct{}{} + } + + portBinding := nat.PortBinding{ + HostIP: port.HostIP, + HostPort: strconv.Itoa(int(port.HostPort)), + } + bslice, exists := bindings[p] + if !exists { + bslice = []nat.PortBinding{} + } + bindings[p] = append(bslice, portBinding) + } + + return exposedPorts, bindings, nil +} + +func fromRestartPolicyName(m string) string { + switch m { + case "always": + return containers.RestartPolicyAny + case "on-failure": + return containers.RestartPolicyOnFailure + case "no", "": + fallthrough + default: + return containers.RestartPolicyNone + } +} + +func toRestartPolicy(p string) container.RestartPolicy { + switch p { + case containers.RestartPolicyAny: + return container.RestartPolicy{Name: "always"} + case containers.RestartPolicyOnFailure: + return container.RestartPolicy{Name: "on-failure"} + case containers.RestartPolicyNone: + fallthrough + default: + return container.RestartPolicy{Name: "no"} + } +} diff --git a/local/convert_test.go b/local/convert_test.go new file mode 100644 index 00000000..987b6c98 --- /dev/null +++ b/local/convert_test.go @@ -0,0 +1,103 @@ +// +build local + +/* + 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 local + +import ( + "testing" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "gotest.tools/v3/assert" + + "github.com/docker/compose-cli/api/containers" +) + +func TestToRuntimeConfig(t *testing.T) { + t.Parallel() + m := &types.ContainerJSON{ + Config: &container.Config{ + Env: []string{"FOO1=BAR1", "FOO2=BAR2"}, + Labels: map[string]string{"foo1": "bar1", "foo2": "bar2"}, + }, + } + rc := toRuntimeConfig(m) + res := &containers.RuntimeConfig{ + Env: map[string]string{"FOO1": "BAR1", "FOO2": "BAR2"}, + Labels: []string{"foo1=bar1", "foo2=bar2"}, + } + assert.DeepEqual(t, rc, res) +} + +func TestToHostConfig(t *testing.T) { + t.Parallel() + base := &types.ContainerJSONBase{ + HostConfig: &container.HostConfig{ + AutoRemove: true, + RestartPolicy: container.RestartPolicy{ + Name: "", + }, + Resources: container.Resources{ + NanoCPUs: 750000000, + Memory: 512 * 1024 * 1024, + }, + }, + } + m := &types.ContainerJSON{ + Config: &container.Config{ + Env: []string{"FOO1=BAR1", "FOO2=BAR2"}, + Labels: map[string]string{"foo1": "bar1", "foo2": "bar2"}, + }, + ContainerJSONBase: base, + } + hc := toHostConfig(m) + res := &containers.HostConfig{ + AutoRemove: true, + RestartPolicy: containers.RestartPolicyNone, + CPULimit: 0.75, + MemoryLimit: 512 * 1024 * 1024, + } + assert.DeepEqual(t, hc, res) +} + +func TestFromRestartPolicyName(t *testing.T) { + t.Parallel() + moby := []string{"always", "on-failure", "no", ""} + ours := []string{ + containers.RestartPolicyAny, + containers.RestartPolicyOnFailure, + containers.RestartPolicyNone, + containers.RestartPolicyNone, + } + for i, p := range moby { + assert.Equal(t, fromRestartPolicyName(p), ours[i]) + } +} + +func TestToRestartPolicy(t *testing.T) { + t.Parallel() + ours := []string{containers.RestartPolicyAny, containers.RestartPolicyOnFailure, containers.RestartPolicyNone} + moby := []container.RestartPolicy{ + {Name: "always"}, + {Name: "on-failure"}, + {Name: "no"}, + } + for i, p := range ours { + assert.Equal(t, toRestartPolicy(p), moby[i]) + } +} diff --git a/local/e2e/backend_test.go b/local/e2e/backend_test.go index ec2c4a3a..6a7bf458 100644 --- a/local/e2e/backend_test.go +++ b/local/e2e/backend_test.go @@ -43,12 +43,13 @@ func TestMain(m *testing.M) { os.Exit(exitCode) } -func TestLocalBackend(t *testing.T) { +func TestLocalBackendRun(t *testing.T) { c := NewParallelE2eCLI(t, binDir) c.RunDockerCmd("context", "create", "local", "test-context").Assert(t, icmd.Success) c.RunDockerCmd("context", "use", "test-context").Assert(t, icmd.Success) t.Run("run", func(t *testing.T) { + t.Parallel() res := c.RunDockerCmd("run", "-d", "nginx") containerName := strings.TrimSpace(res.Combined()) t.Cleanup(func() { @@ -59,6 +60,7 @@ func TestLocalBackend(t *testing.T) { }) t.Run("run rm", func(t *testing.T) { + t.Parallel() res := c.RunDockerCmd("run", "--rm", "-d", "nginx") containerName := strings.TrimSpace(res.Combined()) t.Cleanup(func() { @@ -87,7 +89,20 @@ func TestLocalBackend(t *testing.T) { res.Assert(t, icmd.Expected{Out: "0.0.0.0:8080->80/tcp"}) }) + t.Run("run with volume", func(t *testing.T) { + t.Parallel() + t.Cleanup(func() { + _ = c.RunDockerOrExitError("volume", "rm", "local-test") + }) + c.RunDockerCmd("volume", "create", "local-test") + c.RunDockerCmd("run", "--rm", "-d", "--volume", "local-test:/data", "alpine", "sh", "-c", `echo "testdata" > /data/test`) + // FIXME: Remove sleep when race to attach to dead container is fixed + res := c.RunDockerOrExitError("run", "--rm", "--volume", "local-test:/data", "alpine", "sh", "-c", "cat /data/test && sleep 1") + res.Assert(t, icmd.Expected{Out: "testdata"}) + }) + t.Run("inspect not found", func(t *testing.T) { + t.Parallel() res := c.RunDockerOrExitError("inspect", "nonexistentcontainer") res.Assert(t, icmd.Expected{ ExitCode: 1, @@ -95,3 +110,27 @@ func TestLocalBackend(t *testing.T) { }) }) } + +func TestLocalBackendVolumes(t *testing.T) { + c := NewParallelE2eCLI(t, binDir) + c.RunDockerCmd("context", "create", "local", "test-context").Assert(t, icmd.Success) + c.RunDockerCmd("context", "use", "test-context").Assert(t, icmd.Success) + + t.Run("volume crud", func(t *testing.T) { + t.Parallel() + name := "crud" + t.Cleanup(func() { + _ = c.RunDockerOrExitError("volume", "rm", name) + }) + res := c.RunDockerCmd("volume", "create", name) + res.Assert(t, icmd.Expected{Out: name}) + res = c.RunDockerCmd("volume", "ls") + res.Assert(t, icmd.Expected{Out: name}) + res = c.RunDockerCmd("volume", "inspect", name) + res.Assert(t, icmd.Expected{Out: fmt.Sprintf(`"ID": "%s"`, name)}) + res = c.RunDockerCmd("volume", "rm", name) + res.Assert(t, icmd.Expected{Out: name}) + res = c.RunDockerOrExitError("volume", "inspect", name) + res.Assert(t, icmd.Expected{ExitCode: 1}) + }) +} diff --git a/local/volumes.go b/local/volumes.go new file mode 100644 index 00000000..2f56d444 --- /dev/null +++ b/local/volumes.go @@ -0,0 +1,84 @@ +// +build local + +/* + 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 local + +import ( + "context" + "fmt" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/volume" + "github.com/docker/docker/client" + + "github.com/docker/compose-cli/api/volumes" +) + +type volumeService struct { + apiClient *client.Client +} + +func (vs *volumeService) List(ctx context.Context) ([]volumes.Volume, error) { + l, err := vs.apiClient.VolumeList(ctx, filters.NewArgs()) + if err != nil { + return []volumes.Volume{}, err + } + + res := []volumes.Volume{} + for _, v := range l.Volumes { + res = append(res, volumes.Volume{ + ID: v.Name, + Description: description(v), + }) + } + + return res, nil +} + +func (vs *volumeService) Create(ctx context.Context, name string, options interface{}) (volumes.Volume, error) { + v, err := vs.apiClient.VolumeCreate(ctx, volume.VolumeCreateBody{ + Driver: "local", + DriverOpts: nil, + Labels: nil, + Name: name, + }) + if err != nil { + return volumes.Volume{}, err + } + return volumes.Volume{ID: name, Description: description(&v)}, nil +} + +func (vs *volumeService) Delete(ctx context.Context, volumeID string, options interface{}) error { + if err := vs.apiClient.VolumeRemove(ctx, volumeID, false); err != nil { + return err + } + return nil +} + +func (vs *volumeService) Inspect(ctx context.Context, volumeID string) (volumes.Volume, error) { + v, err := vs.apiClient.VolumeInspect(ctx, volumeID) + if err != nil { + return volumes.Volume{}, err + } + return volumes.Volume{ID: volumeID, Description: description(&v)}, nil +} + +func description(v *types.Volume) string { + return fmt.Sprintf("Created %s", v.CreatedAt) +}