diff --git a/aci/aci.go b/aci/aci.go index 4a251d3f4..795a8f493 100644 --- a/aci/aci.go +++ b/aci/aci.go @@ -129,6 +129,34 @@ func getACIContainerGroup(ctx context.Context, aciContext store.AciContext, cont return containerGroupsClient.Get(ctx, aciContext.ResourceGroup, containerGroupName) } +func getACIContainerGroups(ctx context.Context, subscriptionID string, resourceGroup string) ([]containerinstance.ContainerGroup, error) { + groupsClient, err := login.NewContainerGroupsClient(subscriptionID) + if err != nil { + return nil, err + } + var containerGroups []containerinstance.ContainerGroup + result, err := groupsClient.ListByResourceGroup(ctx, resourceGroup) + if err != nil { + return nil, err + } + + for result.NotDone() { + containerGroups = append(containerGroups, result.Values()...) + if err := result.NextWithContext(ctx); err != nil { + return nil, err + } + } + var groups []containerinstance.ContainerGroup + for _, group := range containerGroups { + group, err := groupsClient.Get(ctx, resourceGroup, *group.Name) + if err != nil { + return nil, err + } + groups = append(groups, group) + } + return groups, nil +} + func deleteACIContainerGroup(ctx context.Context, aciContext store.AciContext, containerGroupName string) (containerinstance.ContainerGroup, error) { containerGroupsClient, err := login.NewContainerGroupsClient(aciContext.SubscriptionID) if err != nil { diff --git a/aci/backend.go b/aci/backend.go index ceabd7cf2..a5e084253 100644 --- a/aci/backend.go +++ b/aci/backend.go @@ -18,35 +18,29 @@ package aci import ( "context" - "fmt" - "io" - "net/http" - "strconv" "strings" "github.com/Azure/azure-sdk-for-go/services/containerinstance/mgmt/2018-10-01/containerinstance" - "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/to" - "github.com/compose-spec/compose-go/types" "github.com/pkg/errors" - "github.com/sirupsen/logrus" "github.com/docker/compose-cli/aci/convert" "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/secrets" + "github.com/docker/compose-cli/api/volumes" "github.com/docker/compose-cli/backend" apicontext "github.com/docker/compose-cli/context" "github.com/docker/compose-cli/context/cloud" "github.com/docker/compose-cli/context/store" - "github.com/docker/compose-cli/errdefs" ) const ( backendType = store.AciContextType singleContainerTag = "docker-single-container" composeContainerTag = "docker-compose-application" + dockerVolumeTag = "docker-volume" composeContainerSeparator = "_" ) @@ -109,12 +103,16 @@ func getAciAPIService(aciCtx store.AciContext) *aciAPIService { aciComposeService: &aciComposeService{ ctx: aciCtx, }, + aciVolumeService: &aciVolumeService{ + aciContext: aciCtx, + }, } } type aciAPIService struct { *aciContainerService *aciComposeService + *aciVolumeService } func (a *aciAPIService) ContainerService() containers.Service { @@ -129,58 +127,8 @@ func (a *aciAPIService) SecretsService() secrets.Service { return nil } -type aciContainerService struct { - ctx store.AciContext -} - -func (cs *aciContainerService) List(ctx context.Context, all bool) ([]containers.Container, error) { - containerGroups, err := getContainerGroups(ctx, cs.ctx.SubscriptionID, cs.ctx.ResourceGroup) - if err != nil { - return []containers.Container{}, err - } - var res []containers.Container - for _, group := range containerGroups { - if group.Containers == nil || len(*group.Containers) < 1 { - return []containers.Container{}, fmt.Errorf("no containers found in ACI container group %s", *group.Name) - } - - for _, container := range *group.Containers { - if isContainerVisible(container, group, all) { - continue - } - c := convert.ContainerGroupToContainer(getContainerID(group, container), group, container) - res = append(res, c) - } - } - return res, nil -} - -func getContainerGroups(ctx context.Context, subscriptionID string, resourceGroup string) ([]containerinstance.ContainerGroup, error) { - groupsClient, err := login.NewContainerGroupsClient(subscriptionID) - if err != nil { - return nil, err - } - var containerGroups []containerinstance.ContainerGroup - result, err := groupsClient.ListByResourceGroup(ctx, resourceGroup) - if err != nil { - return []containerinstance.ContainerGroup{}, err - } - - for result.NotDone() { - containerGroups = append(containerGroups, result.Values()...) - if err := result.NextWithContext(ctx); err != nil { - return []containerinstance.ContainerGroup{}, err - } - } - var groups []containerinstance.ContainerGroup - for _, group := range containerGroups { - group, err := groupsClient.Get(ctx, resourceGroup, *group.Name) - if err != nil { - return []containerinstance.ContainerGroup{}, err - } - groups = append(groups, group) - } - return groups, nil +func (a *aciAPIService) VolumeService() volumes.Service { + return a.aciVolumeService } func getContainerID(group containerinstance.ContainerGroup, container containerinstance.Container) string { @@ -195,26 +143,6 @@ func isContainerVisible(container containerinstance.Container, group containerin return *container.Name == convert.ComposeDNSSidecarName || (!showAll && convert.GetStatus(container, group) != convert.StatusRunning) } -func (cs *aciContainerService) Run(ctx context.Context, r containers.ContainerConfig) error { - if strings.Contains(r.ID, composeContainerSeparator) { - return errors.New(fmt.Sprintf("invalid container name. ACI container name cannot include %q", composeContainerSeparator)) - } - - project, err := convert.ContainerToComposeProject(r) - if err != nil { - return err - } - - logrus.Debugf("Running container %q with name %q\n", r.Image, r.ID) - groupDefinition, err := convert.ToContainerGroup(ctx, cs.ctx, project) - if err != nil { - return err - } - addTag(&groupDefinition, singleContainerTag) - - return createACIContainers(ctx, cs.ctx, groupDefinition) -} - func addTag(groupDefinition *containerinstance.ContainerGroup, tagName string) { if groupDefinition.Tags == nil { groupDefinition.Tags = make(map[string]*string, 1) @@ -222,53 +150,6 @@ func addTag(groupDefinition *containerinstance.ContainerGroup, tagName string) { groupDefinition.Tags[tagName] = to.StringPtr(tagName) } -func (cs *aciContainerService) Start(ctx context.Context, containerID string) error { - groupName, containerName := getGroupAndContainerName(containerID) - if groupName != containerID { - msg := "cannot start specified service %q from compose application %q, you can update and restart the entire compose app with docker compose up --project-name %s" - return errors.New(fmt.Sprintf(msg, containerName, groupName, groupName)) - } - - containerGroupsClient, err := login.NewContainerGroupsClient(cs.ctx.SubscriptionID) - if err != nil { - return err - } - - future, err := containerGroupsClient.Start(ctx, cs.ctx.ResourceGroup, containerName) - if err != nil { - var aerr autorest.DetailedError - if ok := errors.As(err, &aerr); ok { - if aerr.StatusCode == http.StatusNotFound { - return errdefs.ErrNotFound - } - } - return err - } - - return future.WaitForCompletionRef(ctx, containerGroupsClient.Client) -} - -func (cs *aciContainerService) Stop(ctx context.Context, containerID string, timeout *uint32) error { - if timeout != nil && *timeout != uint32(0) { - return errors.Errorf("ACI integration does not support setting a timeout to stop a container before killing it.") - } - groupName, containerName := getGroupAndContainerName(containerID) - if groupName != containerID { - msg := "cannot stop service %q from compose application %q, you can stop the entire compose app with docker stop %s" - return errors.New(fmt.Sprintf(msg, containerName, groupName, groupName)) - } - return stopACIContainerGroup(ctx, cs.ctx, groupName) -} - -func (cs *aciContainerService) Kill(ctx context.Context, containerID string, _ string) error { - groupName, containerName := getGroupAndContainerName(containerID) - if groupName != containerID { - msg := "cannot kill service %q from compose application %q, you can kill the entire compose app with docker kill %s" - return errors.New(fmt.Sprintf(msg, containerName, groupName, groupName)) - } - return stopACIContainerGroup(ctx, cs.ctx, groupName) // As ACI doesn't have a kill command, we are using the stop implementation instead -} - func getGroupAndContainerName(containerID string) (string, string) { tokens := strings.Split(containerID, composeContainerSeparator) groupName := tokens[0] @@ -279,244 +160,3 @@ func getGroupAndContainerName(containerID string) (string, string) { } return groupName, containerName } - -func (cs *aciContainerService) Exec(ctx context.Context, name string, request containers.ExecRequest) error { - err := verifyExecCommand(request.Command) - if err != nil { - return err - } - groupName, containerAciName := getGroupAndContainerName(name) - containerExecResponse, err := execACIContainer(ctx, cs.ctx, request.Command, groupName, containerAciName) - if err != nil { - return err - } - - return exec( - context.Background(), - *containerExecResponse.WebSocketURI, - *containerExecResponse.Password, - request, - ) -} - -func verifyExecCommand(command string) error { - tokens := strings.Split(command, " ") - if len(tokens) > 1 { - return errors.New("ACI exec command does not accept arguments to the command. " + - "Only the binary should be specified") - } - return nil -} - -func (cs *aciContainerService) Logs(ctx context.Context, containerName string, req containers.LogsRequest) error { - groupName, containerAciName := getGroupAndContainerName(containerName) - var tail *int32 - - if req.Follow { - return streamLogs(ctx, cs.ctx, groupName, containerAciName, req) - } - - if req.Tail != "all" { - reqTail, err := strconv.Atoi(req.Tail) - if err != nil { - return err - } - i32 := int32(reqTail) - tail = &i32 - } - - logs, err := getACIContainerLogs(ctx, cs.ctx, groupName, containerAciName, tail) - if err != nil { - return err - } - - _, err = fmt.Fprint(req.Writer, logs) - return err -} - -func (cs *aciContainerService) Delete(ctx context.Context, containerID string, request containers.DeleteRequest) error { - groupName, containerName := getGroupAndContainerName(containerID) - if groupName != containerID { - msg := "cannot delete service %q from compose application %q, you can delete the entire compose app with docker compose down --project-name %s" - return errors.New(fmt.Sprintf(msg, containerName, groupName, groupName)) - } - - if !request.Force { - containerGroupsClient, err := login.NewContainerGroupsClient(cs.ctx.SubscriptionID) - if err != nil { - return err - } - - cg, err := containerGroupsClient.Get(ctx, cs.ctx.ResourceGroup, groupName) - if err != nil { - if cg.StatusCode == http.StatusNotFound { - return errdefs.ErrNotFound - } - return err - } - - for _, container := range *cg.Containers { - status := convert.GetStatus(container, cg) - - if status == convert.StatusRunning { - return errdefs.ErrForbidden - } - } - } - - cg, err := deleteACIContainerGroup(ctx, cs.ctx, groupName) - // Delete returns `StatusNoContent` if the group is not found - if cg.StatusCode == http.StatusNoContent { - return errdefs.ErrNotFound - } - if err != nil { - return err - } - - return err -} - -func (cs *aciContainerService) Inspect(ctx context.Context, containerID string) (containers.Container, error) { - groupName, containerName := getGroupAndContainerName(containerID) - - cg, err := getACIContainerGroup(ctx, cs.ctx, groupName) - if err != nil { - return containers.Container{}, err - } - if cg.StatusCode == http.StatusNoContent { - return containers.Container{}, errdefs.ErrNotFound - } - - var cc containerinstance.Container - var found = false - for _, c := range *cg.Containers { - if to.String(c.Name) == containerName { - cc = c - found = true - break - } - } - if !found { - return containers.Container{}, errdefs.ErrNotFound - } - - return convert.ContainerGroupToContainer(containerID, cg, cc), nil -} - -type aciComposeService struct { - ctx store.AciContext -} - -func (cs *aciComposeService) Up(ctx context.Context, project *types.Project) error { - logrus.Debugf("Up on project with name %q\n", project.Name) - groupDefinition, err := convert.ToContainerGroup(ctx, cs.ctx, *project) - addTag(&groupDefinition, composeContainerTag) - - if err != nil { - return err - } - return createOrUpdateACIContainers(ctx, cs.ctx, groupDefinition) -} - -func (cs *aciComposeService) Down(ctx context.Context, project string) error { - logrus.Debugf("Down on project with name %q\n", project) - - cg, err := deleteACIContainerGroup(ctx, cs.ctx, project) - if err != nil { - return err - } - if cg.StatusCode == http.StatusNoContent { - return errdefs.ErrNotFound - } - - return err -} - -func (cs *aciComposeService) Ps(ctx context.Context, project string) ([]compose.ServiceStatus, error) { - groupsClient, err := login.NewContainerGroupsClient(cs.ctx.SubscriptionID) - if err != nil { - return nil, err - } - - group, err := groupsClient.Get(ctx, cs.ctx.ResourceGroup, project) - if err != nil { - return []compose.ServiceStatus{}, err - } - - if group.Containers == nil || len(*group.Containers) < 1 { - return []compose.ServiceStatus{}, fmt.Errorf("no containers found in ACI container group %s", project) - } - - res := []compose.ServiceStatus{} - for _, container := range *group.Containers { - if isContainerVisible(container, group, false) { - continue - } - res = append(res, convert.ContainerGroupToServiceStatus(getContainerID(group, container), group, container)) - } - return res, nil -} - -func (cs *aciComposeService) List(ctx context.Context, project string) ([]compose.Stack, error) { - containerGroups, err := getContainerGroups(ctx, cs.ctx.SubscriptionID, cs.ctx.ResourceGroup) - if err != nil { - return []compose.Stack{}, err - } - - stacks := []compose.Stack{} - for _, group := range containerGroups { - if _, found := group.Tags[composeContainerTag]; !found { - continue - } - if project != "" && *group.Name != project { - continue - } - state := compose.RUNNING - for _, container := range *group.ContainerGroupProperties.Containers { - containerState := convert.GetStatus(container, group) - if containerState != compose.RUNNING { - state = containerState - break - } - } - stacks = append(stacks, compose.Stack{ - ID: *group.ID, - Name: *group.Name, - Status: state, - }) - } - return stacks, nil -} - -func (cs *aciComposeService) Logs(ctx context.Context, project string, w io.Writer) error { - return errdefs.ErrNotImplemented -} - -func (cs *aciComposeService) Convert(ctx context.Context, project *types.Project) ([]byte, error) { - return nil, errdefs.ErrNotImplemented -} - -type aciCloudService struct { - loginService login.AzureLoginServiceAPI -} - -func (cs *aciCloudService) Login(ctx context.Context, params interface{}) error { - opts, ok := params.(LoginParams) - if !ok { - return errors.New("Could not read azure LoginParams struct from generic parameter") - } - if opts.ClientID != "" { - return cs.loginService.LoginServicePrincipal(opts.ClientID, opts.ClientSecret, opts.TenantID) - } - return cs.loginService.Login(ctx, opts.TenantID) -} - -func (cs *aciCloudService) Logout(ctx context.Context) error { - return cs.loginService.Logout(ctx) -} - -func (cs *aciCloudService) CreateContextData(ctx context.Context, params interface{}) (interface{}, string, error) { - contextHelper := newContextCreateHelper() - createOpts := params.(ContextParams) - return contextHelper.createContextData(ctx, createOpts) -} diff --git a/aci/cloud.go b/aci/cloud.go new file mode 100644 index 000000000..ff761800a --- /dev/null +++ b/aci/cloud.go @@ -0,0 +1,50 @@ +/* + Copyright 2020 Docker, Inc. + + 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/pkg/errors" + + "github.com/docker/compose-cli/aci/login" +) + +type aciCloudService struct { + loginService login.AzureLoginServiceAPI +} + +func (cs *aciCloudService) Login(ctx context.Context, params interface{}) error { + opts, ok := params.(LoginParams) + if !ok { + return errors.New("could not read Azure LoginParams struct from generic parameter") + } + if opts.ClientID != "" { + return cs.loginService.LoginServicePrincipal(opts.ClientID, opts.ClientSecret, opts.TenantID) + } + return cs.loginService.Login(ctx, opts.TenantID) +} + +func (cs *aciCloudService) Logout(ctx context.Context) error { + return cs.loginService.Logout(ctx) +} + +func (cs *aciCloudService) CreateContextData(ctx context.Context, params interface{}) (interface{}, string, error) { + contextHelper := newContextCreateHelper() + createOpts := params.(ContextParams) + return contextHelper.createContextData(ctx, createOpts) +} diff --git a/aci/compose.go b/aci/compose.go new file mode 100644 index 000000000..fe6a3e1eb --- /dev/null +++ b/aci/compose.go @@ -0,0 +1,126 @@ +/* + Copyright 2020 Docker, Inc. + + 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" + "fmt" + "io" + "net/http" + + "github.com/compose-spec/compose-go/types" + "github.com/sirupsen/logrus" + + "github.com/docker/compose-cli/aci/convert" + "github.com/docker/compose-cli/aci/login" + "github.com/docker/compose-cli/api/compose" + "github.com/docker/compose-cli/context/store" + "github.com/docker/compose-cli/errdefs" +) + +type aciComposeService struct { + ctx store.AciContext +} + +func (cs *aciComposeService) Up(ctx context.Context, project *types.Project) error { + logrus.Debugf("Up on project with name %q", project.Name) + groupDefinition, err := convert.ToContainerGroup(ctx, cs.ctx, *project) + addTag(&groupDefinition, composeContainerTag) + + if err != nil { + return err + } + return createOrUpdateACIContainers(ctx, cs.ctx, groupDefinition) +} + +func (cs *aciComposeService) Down(ctx context.Context, project string) error { + logrus.Debugf("Down on project with name %q", project) + + cg, err := deleteACIContainerGroup(ctx, cs.ctx, project) + if err != nil { + return err + } + if cg.StatusCode == http.StatusNoContent { + return errdefs.ErrNotFound + } + + return err +} + +func (cs *aciComposeService) Ps(ctx context.Context, project string) ([]compose.ServiceStatus, error) { + groupsClient, err := login.NewContainerGroupsClient(cs.ctx.SubscriptionID) + if err != nil { + return nil, err + } + + group, err := groupsClient.Get(ctx, cs.ctx.ResourceGroup, project) + if err != nil { + return nil, err + } + + if group.Containers == nil || len(*group.Containers) == 0 { + return nil, fmt.Errorf("no containers found in ACI container group %s", project) + } + + res := []compose.ServiceStatus{} + for _, container := range *group.Containers { + if isContainerVisible(container, group, false) { + continue + } + res = append(res, convert.ContainerGroupToServiceStatus(getContainerID(group, container), group, container)) + } + return res, nil +} + +func (cs *aciComposeService) List(ctx context.Context, project string) ([]compose.Stack, error) { + containerGroups, err := getACIContainerGroups(ctx, cs.ctx.SubscriptionID, cs.ctx.ResourceGroup) + if err != nil { + return nil, err + } + + stacks := []compose.Stack{} + for _, group := range containerGroups { + if _, found := group.Tags[composeContainerTag]; !found { + continue + } + if project != "" && *group.Name != project { + continue + } + state := compose.RUNNING + for _, container := range *group.ContainerGroupProperties.Containers { + containerState := convert.GetStatus(container, group) + if containerState != compose.RUNNING { + state = containerState + break + } + } + stacks = append(stacks, compose.Stack{ + ID: *group.ID, + Name: *group.Name, + Status: state, + }) + } + return stacks, nil +} + +func (cs *aciComposeService) Logs(ctx context.Context, project string, w io.Writer) error { + return errdefs.ErrNotImplemented +} + +func (cs *aciComposeService) Convert(ctx context.Context, project *types.Project) ([]byte, error) { + return nil, errdefs.ErrNotImplemented +} diff --git a/aci/containers.go b/aci/containers.go new file mode 100644 index 000000000..61c50b656 --- /dev/null +++ b/aci/containers.go @@ -0,0 +1,253 @@ +/* + Copyright 2020 Docker, Inc. + + 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" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/Azure/azure-sdk-for-go/services/containerinstance/mgmt/2018-10-01/containerinstance" + "github.com/Azure/go-autorest/autorest" + "github.com/Azure/go-autorest/autorest/to" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "github.com/docker/compose-cli/aci/convert" + "github.com/docker/compose-cli/aci/login" + "github.com/docker/compose-cli/api/containers" + "github.com/docker/compose-cli/context/store" + "github.com/docker/compose-cli/errdefs" +) + +type aciContainerService struct { + ctx store.AciContext +} + +func (cs *aciContainerService) List(ctx context.Context, all bool) ([]containers.Container, error) { + containerGroups, err := getACIContainerGroups(ctx, cs.ctx.SubscriptionID, cs.ctx.ResourceGroup) + if err != nil { + return nil, err + } + var res []containers.Container + for _, group := range containerGroups { + if group.Containers == nil || len(*group.Containers) == 0 { + return nil, fmt.Errorf("no containers found in ACI container group %s", *group.Name) + } + + for _, container := range *group.Containers { + if isContainerVisible(container, group, all) { + continue + } + c := convert.ContainerGroupToContainer(getContainerID(group, container), group, container) + res = append(res, c) + } + } + return res, nil +} + +func (cs *aciContainerService) Run(ctx context.Context, r containers.ContainerConfig) error { + if strings.Contains(r.ID, composeContainerSeparator) { + return fmt.Errorf("invalid container name. ACI container name cannot include %q", composeContainerSeparator) + } + + project, err := convert.ContainerToComposeProject(r) + if err != nil { + return err + } + + logrus.Debugf("Running container %q with name %q", r.Image, r.ID) + groupDefinition, err := convert.ToContainerGroup(ctx, cs.ctx, project) + if err != nil { + return err + } + addTag(&groupDefinition, singleContainerTag) + + return createACIContainers(ctx, cs.ctx, groupDefinition) +} + +func (cs *aciContainerService) Start(ctx context.Context, containerID string) error { + groupName, containerName := getGroupAndContainerName(containerID) + if groupName != containerID { + msg := "cannot start specified service %q from compose application %q, you can update and restart the entire compose app with docker compose up --project-name %s" + return fmt.Errorf(msg, containerName, groupName, groupName) + } + + containerGroupsClient, err := login.NewContainerGroupsClient(cs.ctx.SubscriptionID) + if err != nil { + return err + } + + future, err := containerGroupsClient.Start(ctx, cs.ctx.ResourceGroup, containerName) + if err != nil { + var aerr autorest.DetailedError + if ok := errors.As(err, &aerr); ok { + if aerr.StatusCode == http.StatusNotFound { + return errdefs.ErrNotFound + } + } + return err + } + + return future.WaitForCompletionRef(ctx, containerGroupsClient.Client) +} + +func (cs *aciContainerService) Stop(ctx context.Context, containerID string, timeout *uint32) error { + if timeout != nil && *timeout != uint32(0) { + return fmt.Errorf("the ACI integration does not support setting a timeout to stop a container before killing it") + } + groupName, containerName := getGroupAndContainerName(containerID) + if groupName != containerID { + msg := "cannot stop service %q from compose application %q, you can stop the entire compose app with docker stop %s" + return fmt.Errorf(msg, containerName, groupName, groupName) + } + return stopACIContainerGroup(ctx, cs.ctx, groupName) +} + +func (cs *aciContainerService) Kill(ctx context.Context, containerID string, _ string) error { + groupName, containerName := getGroupAndContainerName(containerID) + if groupName != containerID { + msg := "cannot kill service %q from compose application %q, you can kill the entire compose app with docker kill %s" + return fmt.Errorf(msg, containerName, groupName, groupName) + } + return stopACIContainerGroup(ctx, cs.ctx, groupName) // As ACI doesn't have a kill command, we are using the stop implementation instead +} + +func (cs *aciContainerService) Exec(ctx context.Context, name string, request containers.ExecRequest) error { + err := verifyExecCommand(request.Command) + if err != nil { + return err + } + groupName, containerAciName := getGroupAndContainerName(name) + containerExecResponse, err := execACIContainer(ctx, cs.ctx, request.Command, groupName, containerAciName) + if err != nil { + return err + } + + return exec( + context.Background(), + *containerExecResponse.WebSocketURI, + *containerExecResponse.Password, + request, + ) +} + +func verifyExecCommand(command string) error { + tokens := strings.Split(command, " ") + if len(tokens) > 1 { + return errors.New("ACI exec command does not accept arguments to the command. " + + "Only the binary should be specified") + } + return nil +} + +func (cs *aciContainerService) Logs(ctx context.Context, containerName string, req containers.LogsRequest) error { + groupName, containerAciName := getGroupAndContainerName(containerName) + var tail *int32 + + if req.Follow { + return streamLogs(ctx, cs.ctx, groupName, containerAciName, req) + } + + if req.Tail != "all" { + reqTail, err := strconv.Atoi(req.Tail) + if err != nil { + return err + } + i32 := int32(reqTail) + tail = &i32 + } + + logs, err := getACIContainerLogs(ctx, cs.ctx, groupName, containerAciName, tail) + if err != nil { + return err + } + + _, err = fmt.Fprint(req.Writer, logs) + return err +} + +func (cs *aciContainerService) Delete(ctx context.Context, containerID string, request containers.DeleteRequest) error { + groupName, containerName := getGroupAndContainerName(containerID) + if groupName != containerID { + msg := "cannot delete service %q from compose application %q, you can delete the entire compose app with docker compose down --project-name %s" + return fmt.Errorf(msg, containerName, groupName, groupName) + } + + if !request.Force { + containerGroupsClient, err := login.NewContainerGroupsClient(cs.ctx.SubscriptionID) + if err != nil { + return err + } + + cg, err := containerGroupsClient.Get(ctx, cs.ctx.ResourceGroup, groupName) + if err != nil { + if cg.StatusCode == http.StatusNotFound { + return errdefs.ErrNotFound + } + return err + } + + for _, container := range *cg.Containers { + status := convert.GetStatus(container, cg) + + if status == convert.StatusRunning { + return errdefs.ErrForbidden + } + } + } + + cg, err := deleteACIContainerGroup(ctx, cs.ctx, groupName) + // Delete returns `StatusNoContent` if the group is not found + if cg.StatusCode == http.StatusNoContent { + return errdefs.ErrNotFound + } + if err != nil { + return err + } + + return err +} + +func (cs *aciContainerService) Inspect(ctx context.Context, containerID string) (containers.Container, error) { + groupName, containerName := getGroupAndContainerName(containerID) + + cg, err := getACIContainerGroup(ctx, cs.ctx, groupName) + if err != nil { + return containers.Container{}, err + } + if cg.StatusCode == http.StatusNoContent { + return containers.Container{}, errdefs.ErrNotFound + } + + var cc containerinstance.Container + var found = false + for _, c := range *cg.Containers { + if to.String(c.Name) == containerName { + cc = c + found = true + break + } + } + if !found { + return containers.Container{}, errdefs.ErrNotFound + } + + return convert.ContainerGroupToContainer(containerID, cg, cc), nil +} diff --git a/aci/convert/convert.go b/aci/convert/convert.go index eb505b4c1..d35b410b9 100644 --- a/aci/convert/convert.go +++ b/aci/convert/convert.go @@ -56,13 +56,8 @@ const ( func ToContainerGroup(ctx context.Context, aciContext store.AciContext, p types.Project) (containerinstance.ContainerGroup, error) { project := projectAciHelper(p) containerGroupName := strings.ToLower(project.Name) - loginService, err := login.NewAzureLoginService() - if err != nil { - return containerinstance.ContainerGroup{}, err - } - storageHelper := login.StorageAccountHelper{ - LoginService: *loginService, - AciContext: aciContext, + storageHelper := login.StorageLogin{ + AciContext: aciContext, } volumesCache, volumesSlice, err := project.getAciFileVolumes(ctx, storageHelper) if err != nil { @@ -205,7 +200,7 @@ func (p projectAciHelper) getAciSecretVolumes() ([]containerinstance.Volume, err return secretVolumes, nil } -func (p projectAciHelper) getAciFileVolumes(ctx context.Context, helper login.StorageAccountHelper) (map[string]bool, []containerinstance.Volume, error) { +func (p projectAciHelper) getAciFileVolumes(ctx context.Context, helper login.StorageLogin) (map[string]bool, []containerinstance.Volume, error) { azureFileVolumesMap := make(map[string]bool, len(p.Volumes)) var azureFileVolumesSlice []containerinstance.Volume for name, v := range p.Volumes { diff --git a/aci/login/client.go b/aci/login/client.go index b9dc7c891..7de5d327e 100644 --- a/aci/login/client.go +++ b/aci/login/client.go @@ -67,6 +67,19 @@ func NewStorageAccountsClient(subscriptionID string) (storage.AccountsClient, er return containerGroupsClient, nil } +// NewFileShareClient get client to manipulate file shares +func NewFileShareClient(subscriptionID string) (storage.FileSharesClient, error) { + containerGroupsClient := storage.NewFileSharesClient(subscriptionID) + err := setupClient(&containerGroupsClient.Client) + if err != nil { + return storage.FileSharesClient{}, err + } + containerGroupsClient.PollingDelay = 5 * time.Second + containerGroupsClient.RetryAttempts = 30 + containerGroupsClient.RetryDuration = 1 * time.Second + return containerGroupsClient, nil +} + // NewSubscriptionsClient get subscription client func NewSubscriptionsClient() (subscription.SubscriptionsClient, error) { subc := subscription.NewSubscriptionsClient() diff --git a/aci/login/storage_helper.go b/aci/login/storagelogin.go similarity index 83% rename from aci/login/storage_helper.go rename to aci/login/storagelogin.go index 0da3bd2a5..13a3923f1 100644 --- a/aci/login/storage_helper.go +++ b/aci/login/storagelogin.go @@ -25,14 +25,13 @@ import ( "github.com/docker/compose-cli/context/store" ) -// StorageAccountHelper helper for Azure Storage Account -type StorageAccountHelper struct { - LoginService AzureLoginService - AciContext store.AciContext +// StorageLogin helper for Azure Storage Login +type StorageLogin struct { + AciContext store.AciContext } // GetAzureStorageAccountKey retrieves the storage account ket from the current azure login -func (helper StorageAccountHelper) GetAzureStorageAccountKey(ctx context.Context, accountName string) (string, error) { +func (helper StorageLogin) GetAzureStorageAccountKey(ctx context.Context, accountName string) (string, error) { client, err := NewStorageAccountsClient(helper.AciContext.SubscriptionID) if err != nil { return "", err diff --git a/aci/volumes.go b/aci/volumes.go new file mode 100644 index 000000000..250f0ffa5 --- /dev/null +++ b/aci/volumes.go @@ -0,0 +1,234 @@ +/* + Copyright 2020 Docker, Inc. + + 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" + "fmt" + "net/http" + "strings" + + "github.com/pkg/errors" + + "github.com/Azure/azure-sdk-for-go/services/storage/mgmt/2019-06-01/storage" + "github.com/Azure/go-autorest/autorest/to" + + "github.com/docker/compose-cli/aci/login" + "github.com/docker/compose-cli/api/volumes" + "github.com/docker/compose-cli/context/store" + "github.com/docker/compose-cli/errdefs" + "github.com/docker/compose-cli/progress" +) + +type aciVolumeService struct { + aciContext store.AciContext +} + +func (cs *aciVolumeService) List(ctx context.Context) ([]volumes.Volume, error) { + accountClient, err := login.NewStorageAccountsClient(cs.aciContext.SubscriptionID) + if err != nil { + return nil, err + } + result, err := accountClient.ListByResourceGroup(ctx, cs.aciContext.ResourceGroup) + if err != nil { + return nil, err + } + accounts := result.Value + fileShareClient, err := login.NewFileShareClient(cs.aciContext.SubscriptionID) + if err != nil { + return nil, err + } + fileShares := []volumes.Volume{} + for _, account := range *accounts { + fileSharePage, err := fileShareClient.List(ctx, cs.aciContext.ResourceGroup, *account.Name, "", "", "") + if err != nil { + return nil, err + } + + for fileSharePage.NotDone() { + values := fileSharePage.Values() + for _, fileShare := range values { + fileShares = append(fileShares, toVolume(account, *fileShare.Name)) + } + if err := fileSharePage.NextWithContext(ctx); err != nil { + return nil, err + } + } + } + return fileShares, nil +} + +// VolumeCreateOptions options to create a new ACI volume +type VolumeCreateOptions struct { + Account string + Fileshare string +} + +func (cs *aciVolumeService) Create(ctx context.Context, options interface{}) (volumes.Volume, error) { + opts, ok := options.(VolumeCreateOptions) + if !ok { + return volumes.Volume{}, errors.New("could not read Azure VolumeCreateOptions struct from generic parameter") + } + w := progress.ContextWriter(ctx) + w.Event(event(opts.Account, progress.Working, "Validating")) + accountClient, err := login.NewStorageAccountsClient(cs.aciContext.SubscriptionID) + if err != nil { + return volumes.Volume{}, err + } + account, err := accountClient.GetProperties(ctx, cs.aciContext.ResourceGroup, opts.Account, "") + if err == nil { + w.Event(event(opts.Account, progress.Done, "Use existing")) + } else { + if account.StatusCode != http.StatusNotFound { + return volumes.Volume{}, err + } + result, err := accountClient.CheckNameAvailability(ctx, storage.AccountCheckNameAvailabilityParameters{ + Name: to.StringPtr(opts.Account), + Type: to.StringPtr("Microsoft.Storage/storageAccounts"), + }) + if err != nil { + return volumes.Volume{}, err + } + if !*result.NameAvailable { + return volumes.Volume{}, errors.New("error: " + *result.Message) + } + parameters := defaultStorageAccountParams(cs.aciContext) + + w.Event(event(opts.Account, progress.Working, "Creating")) + + future, err := accountClient.Create(ctx, cs.aciContext.ResourceGroup, opts.Account, parameters) + if err != nil { + w.Event(errorEvent(opts.Account)) + return volumes.Volume{}, err + } + if err := future.WaitForCompletionRef(ctx, accountClient.Client); err != nil { + w.Event(errorEvent(opts.Account)) + return volumes.Volume{}, err + } + account, err = future.Result(accountClient) + if err != nil { + w.Event(errorEvent(opts.Account)) + return volumes.Volume{}, err + } + w.Event(event(opts.Account, progress.Done, "Created")) + } + w.Event(event(opts.Fileshare, progress.Working, "Creating")) + fileShareClient, err := login.NewFileShareClient(cs.aciContext.SubscriptionID) + if err != nil { + return volumes.Volume{}, err + } + + fileShare, err := fileShareClient.Get(ctx, cs.aciContext.ResourceGroup, *account.Name, opts.Fileshare, "") + if err == nil { + w.Event(errorEvent(opts.Fileshare)) + return volumes.Volume{}, errors.Wrapf(errdefs.ErrAlreadyExists, "Azure fileshare %q already exists", opts.Fileshare) + } + if fileShare.StatusCode != http.StatusNotFound { + w.Event(errorEvent(opts.Fileshare)) + return volumes.Volume{}, err + } + fileShare, err = fileShareClient.Create(ctx, cs.aciContext.ResourceGroup, *account.Name, opts.Fileshare, storage.FileShare{}) + if err != nil { + w.Event(errorEvent(opts.Fileshare)) + return volumes.Volume{}, err + } + w.Event(event(opts.Fileshare, progress.Done, "Created")) + return toVolume(account, *fileShare.Name), nil +} + +func event(resource string, status progress.EventStatus, text string) progress.Event { + return progress.Event{ + ID: resource, + Status: status, + StatusText: text, + } +} + +func errorEvent(resource string) progress.Event { + return progress.Event{ + ID: resource, + Status: progress.Error, + StatusText: "Error", + } +} + +func (cs *aciVolumeService) Delete(ctx context.Context, id string, options interface{}) error { + tokens := strings.Split(id, "@") + if len(tokens) != 2 { + return errors.New("wrong format for volume ID : should be storageaccount@fileshare") + } + storageAccount := tokens[0] + fileshare := tokens[1] + + fileShareClient, err := login.NewFileShareClient(cs.aciContext.SubscriptionID) + if err != nil { + return err + } + fileShareItemsPage, err := fileShareClient.List(ctx, cs.aciContext.ResourceGroup, storageAccount, "", "", "") + if err != nil { + return err + } + fileshares := fileShareItemsPage.Values() + if len(fileshares) == 1 && *fileshares[0].Name == fileshare { + storageAccountsClient, err := login.NewStorageAccountsClient(cs.aciContext.SubscriptionID) + if err != nil { + return err + } + account, err := storageAccountsClient.GetProperties(ctx, cs.aciContext.ResourceGroup, storageAccount, "") + if err != nil { + return err + } + if err == nil { + if _, ok := account.Tags[dockerVolumeTag]; ok { + result, err := storageAccountsClient.Delete(ctx, cs.aciContext.ResourceGroup, storageAccount) + if result.StatusCode == http.StatusNoContent { + return errors.Wrapf(errdefs.ErrNotFound, "storage account %s does not exist", storageAccount) + } + return err + } + } + } + + result, err := fileShareClient.Delete(ctx, cs.aciContext.ResourceGroup, storageAccount, fileshare) + if result.StatusCode == 204 { + return errors.Wrapf(errdefs.ErrNotFound, "fileshare %q", fileshare) + } + return err +} + +func toVolume(account storage.Account, fileShareName string) volumes.Volume { + return volumes.Volume{ + ID: VolumeID(*account.Name, fileShareName), + Description: fmt.Sprintf("Fileshare %s in %s storage account", fileShareName, *account.Name), + } +} + +// VolumeID generate volume ID from azure storage accoun & fileshare +func VolumeID(storageAccount string, fileShareName string) string { + return fmt.Sprintf("%s@%s", storageAccount, fileShareName) +} + +func defaultStorageAccountParams(aciContext store.AciContext) storage.AccountCreateParameters { + tags := map[string]*string{dockerVolumeTag: to.StringPtr(dockerVolumeTag)} + return storage.AccountCreateParameters{ + Location: to.StringPtr(aciContext.Location), + Sku: &storage.Sku{ + Name: storage.StandardLRS, + }, + Tags: tags, + } +} diff --git a/api/client/client.go b/api/client/client.go index a6e19bf22..fc7c1c277 100644 --- a/api/client/client.go +++ b/api/client/client.go @@ -22,6 +22,7 @@ import ( "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/containers" "github.com/docker/compose-cli/api/secrets" + "github.com/docker/compose-cli/api/volumes" "github.com/docker/compose-cli/backend" apicontext "github.com/docker/compose-cli/context" "github.com/docker/compose-cli/context/cloud" @@ -86,3 +87,12 @@ func (c *Client) SecretsService() secrets.Service { return &secretsService{} } + +// VolumeService returns the backend service for the current context +func (c *Client) VolumeService() volumes.Service { + if vs := c.bs.VolumeService(); vs != nil { + return vs + } + + return &volumeService{} +} diff --git a/api/client/volume.go b/api/client/volume.go new file mode 100644 index 000000000..58c89ed8a --- /dev/null +++ b/api/client/volume.go @@ -0,0 +1,39 @@ +/* + Copyright 2020 Docker, Inc. + + 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/volumes" + "github.com/docker/compose-cli/errdefs" +) + +type volumeService struct { +} + +func (c *volumeService) List(ctx context.Context) ([]volumes.Volume, error) { + return nil, errdefs.ErrNotImplemented +} + +func (c *volumeService) Create(ctx context.Context, options interface{}) (volumes.Volume, error) { + return volumes.Volume{}, errdefs.ErrNotImplemented +} + +func (c *volumeService) Delete(ctx context.Context, id string, options interface{}) error { + return errdefs.ErrNotImplemented +} diff --git a/api/volumes/api.go b/api/volumes/api.go new file mode 100644 index 000000000..a753fcea9 --- /dev/null +++ b/api/volumes/api.go @@ -0,0 +1,37 @@ +/* + Copyright 2020 Docker, Inc. + + 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 volumes + +import ( + "context" +) + +// Volume volume info +type Volume struct { + ID string + Description string +} + +// Service interacts with the underlying container backend +type Service interface { + // List returns all available volumes + List(ctx context.Context) ([]Volume, error) + // Create creates a new volume + Create(ctx context.Context, options interface{}) (Volume, error) + // Delete deletes an existing volume + Delete(ctx context.Context, volumeID string, options interface{}) error +} diff --git a/backend/backend.go b/backend/backend.go index 8185f873c..dc7562617 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -26,6 +26,7 @@ import ( "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/containers" "github.com/docker/compose-cli/api/secrets" + "github.com/docker/compose-cli/api/volumes" "github.com/docker/compose-cli/context/cloud" "github.com/docker/compose-cli/errdefs" ) @@ -53,8 +54,9 @@ var backends = struct { // Service aggregates the service interfaces type Service interface { ContainerService() containers.Service - SecretsService() secrets.Service ComposeService() compose.Service + SecretsService() secrets.Service + VolumeService() volumes.Service } // Register adds a typed backend to the registry diff --git a/cli/cmd/volume/acivolume.go b/cli/cmd/volume/acivolume.go new file mode 100644 index 000000000..4216c36d2 --- /dev/null +++ b/cli/cmd/volume/acivolume.go @@ -0,0 +1,115 @@ +/* + Copyright 2020 Docker, Inc. + + 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 volume + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/go-multierror" + + "github.com/spf13/cobra" + + "github.com/docker/compose-cli/aci" + "github.com/docker/compose-cli/api/client" + "github.com/docker/compose-cli/progress" +) + +// ACICommand manage volumes +func ACICommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "volume", + Short: "Manages volumes", + } + + cmd.AddCommand( + createVolume(), + listVolume(), + rmVolume(), + ) + return cmd +} + +func createVolume() *cobra.Command { + aciOpts := aci.VolumeCreateOptions{} + cmd := &cobra.Command{ + Use: "create --storage-account ACCOUNT --fileshare FILESHARE", + Short: "Creates an Azure file share to use as ACI volume.", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + c, err := client.New(ctx) + if err != nil { + return err + } + err = progress.Run(ctx, func(ctx context.Context) error { + if _, err := c.VolumeService().Create(ctx, aciOpts); err != nil { + return err + } + return nil + }) + if err != nil { + return err + } + fmt.Println(aci.VolumeID(aciOpts.Account, aciOpts.Fileshare)) + return nil + }, + } + + cmd.Flags().StringVar(&aciOpts.Account, "storage-account", "", "Storage account name") + cmd.Flags().StringVar(&aciOpts.Fileshare, "fileshare", "", "Fileshare name") + _ = cmd.MarkFlagRequired("fileshare") + _ = cmd.MarkFlagRequired("storage-account") + return cmd +} + +func rmVolume() *cobra.Command { + cmd := &cobra.Command{ + Use: "rm [OPTIONS] VOLUME [VOLUME...]", + Short: "Remove one or more volumes.", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client.New(cmd.Context()) + if err != nil { + return err + } + var errs *multierror.Error + for _, id := range args { + err = c.VolumeService().Delete(cmd.Context(), id, nil) + if err != nil { + errs = multierror.Append(errs, err) + continue + } + fmt.Println(id) + } + if errs != nil { + errs.ErrorFormat = formatErrors + } + return errs.ErrorOrNil() + }, + } + return cmd +} + +func formatErrors(errs []error) string { + messages := make([]string, len(errs)) + for i, err := range errs { + messages[i] = "Error: " + err.Error() + } + return strings.Join(messages, "\n") +} diff --git a/cli/cmd/volume/list.go b/cli/cmd/volume/list.go new file mode 100644 index 000000000..bca775960 --- /dev/null +++ b/cli/cmd/volume/list.go @@ -0,0 +1,66 @@ +/* + Copyright 2020 Docker, Inc. + + 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 volume + +import ( + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/docker/compose-cli/api/client" + "github.com/docker/compose-cli/api/volumes" +) + +func listVolume() *cobra.Command { + cmd := &cobra.Command{ + Use: "ls", + Short: "list available volumes in context.", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client.New(cmd.Context()) + if err != nil { + return err + } + vols, err := c.VolumeService().List(cmd.Context()) + if err != nil { + return err + } + printList(os.Stdout, vols) + return nil + }, + } + return cmd +} + +func printList(out io.Writer, volumes []volumes.Volume) { + printSection(out, func(w io.Writer) { + for _, vol := range volumes { + _, _ = fmt.Fprintf(w, "%s\t%s\n", vol.ID, vol.Description) + } + }, "ID", "DESCRIPTION") +} + +func printSection(out io.Writer, printer func(io.Writer), headers ...string) { + w := tabwriter.NewWriter(out, 20, 1, 3, ' ', 0) + _, _ = fmt.Fprintln(w, strings.Join(headers, "\t")) + printer(w) + _ = w.Flush() +} diff --git a/cli/cmd/volume/list_test.go b/cli/cmd/volume/list_test.go new file mode 100644 index 000000000..a6a1e73d0 --- /dev/null +++ b/cli/cmd/volume/list_test.go @@ -0,0 +1,38 @@ +/* + Copyright 2020 Docker, Inc. + + 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 volume + +import ( + "bytes" + "testing" + + "gotest.tools/v3/golden" + + "github.com/docker/compose-cli/api/volumes" +) + +func TestPrintList(t *testing.T) { + secrets := []volumes.Volume{ + { + ID: "volume@123", + Description: "volume 123", + }, + } + out := &bytes.Buffer{} + printList(out, secrets) + golden.Assert(t, out.String(), "volumes-out.golden") +} diff --git a/cli/cmd/volume/testdata/volumes-out.golden b/cli/cmd/volume/testdata/volumes-out.golden new file mode 100644 index 000000000..9a4039ece --- /dev/null +++ b/cli/cmd/volume/testdata/volumes-out.golden @@ -0,0 +1,2 @@ +ID DESCRIPTION +volume@123 volume 123 diff --git a/cli/main.go b/cli/main.go index 69f9b0ba6..a4eadd1bb 100644 --- a/cli/main.go +++ b/cli/main.go @@ -28,23 +28,15 @@ import ( "time" "github.com/docker/compose-cli/cli/cmd/compose" - "github.com/docker/compose-cli/cli/cmd/logout" - + volume "github.com/docker/compose-cli/cli/cmd/volume" "github.com/docker/compose-cli/errdefs" - "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/spf13/cobra" // Backend registrations _ "github.com/docker/compose-cli/aci" - _ "github.com/docker/compose-cli/ecs" - _ "github.com/docker/compose-cli/ecs/local" - _ "github.com/docker/compose-cli/example" - _ "github.com/docker/compose-cli/local" - "github.com/docker/compose-cli/metrics" - "github.com/docker/compose-cli/cli/cmd" contextcmd "github.com/docker/compose-cli/cli/cmd/context" "github.com/docker/compose-cli/cli/cmd/login" @@ -54,6 +46,11 @@ import ( "github.com/docker/compose-cli/config" apicontext "github.com/docker/compose-cli/context" "github.com/docker/compose-cli/context/store" + _ "github.com/docker/compose-cli/ecs" + _ "github.com/docker/compose-cli/ecs/local" + _ "github.com/docker/compose-cli/example" + _ "github.com/docker/compose-cli/local" + "github.com/docker/compose-cli/metrics" ) var ( @@ -182,6 +179,11 @@ func main() { ctype = cc.Type() } + if ctype == store.AciContextType { + // we can also pass ctype as a parameter to the volume command and customize subcommands, flags, etc. when we have other backend implementations + root.AddCommand(volume.ACICommand()) + } + metrics.Track(ctype, os.Args[1:], root.PersistentFlags()) ctx = apicontext.WithCurrentContext(ctx, currentContext) diff --git a/ecs/backend.go b/ecs/backend.go index ec18fe026..8cc0a392c 100644 --- a/ecs/backend.go +++ b/ecs/backend.go @@ -21,10 +21,10 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" - "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/containers" "github.com/docker/compose-cli/api/secrets" + "github.com/docker/compose-cli/api/volumes" "github.com/docker/compose-cli/backend" apicontext "github.com/docker/compose-cli/context" "github.com/docker/compose-cli/context/cloud" @@ -97,6 +97,10 @@ func (a *ecsAPIService) SecretsService() secrets.Service { return a } +func (a *ecsAPIService) VolumeService() volumes.Service { + return nil +} + func getCloudService() (cloud.Service, error) { return ecsCloudService{}, nil } diff --git a/ecs/local/backend.go b/ecs/local/backend.go index 1310cfec8..c62ed640c 100644 --- a/ecs/local/backend.go +++ b/ecs/local/backend.go @@ -22,11 +22,11 @@ import ( "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/containers" "github.com/docker/compose-cli/api/secrets" - "github.com/docker/docker/client" - + "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/context/store" + "github.com/docker/docker/client" ) const backendType = store.EcsLocalSimulationContextType @@ -58,6 +58,10 @@ func (e ecsLocalSimulation) ContainerService() containers.Service { return nil } +func (e ecsLocalSimulation) VolumeService() volumes.Service { + return nil +} + func (e ecsLocalSimulation) SecretsService() secrets.Service { return nil } diff --git a/example/backend.go b/example/backend.go index 2f9b420cf..23775841c 100644 --- a/example/backend.go +++ b/example/backend.go @@ -29,6 +29,7 @@ import ( "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/containers" "github.com/docker/compose-cli/api/secrets" + "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" @@ -51,6 +52,10 @@ func (a *apiService) SecretsService() secrets.Service { return nil } +func (a *apiService) VolumeService() volumes.Service { + return nil +} + func init() { backend.Register("example", "example", service, cloud.NotImplementedCloudService) } diff --git a/local/backend.go b/local/backend.go index 63acaae60..dfd8f6d4b 100644 --- a/local/backend.go +++ b/local/backend.go @@ -39,6 +39,7 @@ import ( "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/containers" "github.com/docker/compose-cli/api/secrets" + "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" @@ -75,6 +76,10 @@ func (ms *local) SecretsService() secrets.Service { return nil } +func (ms *local) VolumeService() volumes.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/tests/aci-e2e/e2e-aci_test.go b/tests/aci-e2e/e2e-aci_test.go index a31335d4c..bd6911b8d 100644 --- a/tests/aci-e2e/e2e-aci_test.go +++ b/tests/aci-e2e/e2e-aci_test.go @@ -39,7 +39,6 @@ import ( "gotest.tools/v3/poll" "github.com/Azure/azure-sdk-for-go/profiles/2019-03-01/resources/mgmt/resources" - azure_storage "github.com/Azure/azure-sdk-for-go/profiles/2019-03-01/storage/mgmt/storage" "github.com/Azure/azure-storage-file-go/azfile" "github.com/Azure/go-autorest/autorest/to" @@ -48,7 +47,6 @@ import ( "github.com/docker/compose-cli/api/containers" "github.com/docker/compose-cli/context/store" "github.com/docker/compose-cli/errdefs" - "github.com/docker/compose-cli/tests/aci-e2e/storage" . "github.com/docker/compose-cli/tests/framework" ) @@ -131,7 +129,7 @@ func TestContainerRunVolume(t *testing.T) { sID, rg := setupTestResourceGroup(t, c) const ( - testShareName = "dockertestshare" + fileshareName = "dockertestshare" testFileContent = "Volume mounted successfully!" testFileName = "index.html" ) @@ -142,31 +140,78 @@ func TestContainerRunVolume(t *testing.T) { Location: location, ResourceGroup: rg, } - saName := "e2e" + strconv.Itoa(int(time.Now().UnixNano())) - _, cleanupSa := createStorageAccount(t, aciContext, saName) - t.Cleanup(func() { - if err := cleanupSa(); err != nil { - t.Error(err) - } - }) - keys := getStorageKeys(t, aciContext, saName) - assert.Assert(t, len(keys) > 0) - k := *keys[0].Value - cred, u := createFileShare(t, k, testShareName, saName) - uploadFile(t, *cred, u.String(), testFileName, testFileContent) // Used in subtests var ( - container string - hostIP string - endpoint string + container string + hostIP string + endpoint string + volumeID string + accountName = "e2e" + strconv.Itoa(int(time.Now().UnixNano())) ) + t.Run("check volume name validity", func(t *testing.T) { + invalidName := "some-storage-123" + res := c.RunDockerOrExitError("volume", "create", "--storage-account", invalidName, "--fileshare", fileshareName) + res.Assert(t, icmd.Expected{ + ExitCode: 1, + Err: "some-storage-123 is not a valid storage account name. Storage account name must be between 3 and 24 characters in length and use numbers and lower-case letters only.", + }) + }) + + t.Run("create volumes", func(t *testing.T) { + c.RunDockerCmd("volume", "create", "--storage-account", accountName, "--fileshare", fileshareName) + }) + volumeID = accountName + "@" + fileshareName + + t.Cleanup(func() { + c.RunDockerCmd("volume", "rm", volumeID) + res := c.RunDockerCmd("volume", "ls") + lines := lines(res.Stdout()) + assert.Equal(t, len(lines), 1) + }) + + t.Run("create second fileshare", func(t *testing.T) { + c.RunDockerCmd("volume", "create", "--storage-account", accountName, "--fileshare", "dockertestshare2") + }) + volumeID2 := accountName + "@dockertestshare2" + + t.Run("list volumes", func(t *testing.T) { + res := c.RunDockerCmd("volume", "ls") + lines := lines(res.Stdout()) + assert.Equal(t, len(lines), 3) + firstAccount := lines[1] + fields := strings.Fields(firstAccount) + assert.Equal(t, fields[0], volumeID) + secondAccount := lines[2] + fields = strings.Fields(secondAccount) + assert.Equal(t, fields[0], volumeID2) + }) + + t.Run("delete only fileshare", func(t *testing.T) { + c.RunDockerCmd("volume", "rm", volumeID2) + res := c.RunDockerCmd("volume", "ls") + lines := lines(res.Stdout()) + assert.Equal(t, len(lines), 2) + assert.Assert(t, !strings.Contains(res.Stdout(), "dockertestshare2"), "second fileshare still visible after rm") + }) + + t.Run("upload file", func(t *testing.T) { + storageLogin := login.StorageLogin{AciContext: aciContext} + + key, err := storageLogin.GetAzureStorageAccountKey(context.TODO(), accountName) + assert.NilError(t, err) + cred, err := azfile.NewSharedKeyCredential(accountName, key) + assert.NilError(t, err) + u, _ := url.Parse(fmt.Sprintf("https://%s.file.core.windows.net/%s", accountName, fileshareName)) + uploadFile(t, *cred, u.String(), testFileName, testFileContent) + }) + t.Run("run", func(t *testing.T) { mountTarget := "/usr/share/nginx/html" res := c.RunDockerCmd( "run", "-d", - "-v", fmt.Sprintf("%s@%s:%s", saName, testShareName, mountTarget), + "-v", fmt.Sprintf("%s:%s", volumeID, mountTarget), "-p", "80:80", "nginx", ) @@ -189,7 +234,7 @@ func TestContainerRunVolume(t *testing.T) { t.Run("ps", func(t *testing.T) { res := c.RunDockerCmd("ps") - out := strings.Split(strings.TrimSpace(res.Stdout()), "\n") + out := lines(res.Stdout()) l := out[len(out)-1] assert.Assert(t, strings.Contains(l, container), "Looking for %q in line: %s", container, l) assert.Assert(t, strings.Contains(l, "nginx")) @@ -284,6 +329,10 @@ func TestContainerRunVolume(t *testing.T) { }) } +func lines(output string) []string { + return strings.Split(strings.TrimSpace(output), "\n") +} + func TestContainerRunAttached(t *testing.T) { c := NewParallelE2eCLI(t, binDir) _, _ = setupTestResourceGroup(t, c) @@ -374,11 +423,11 @@ func TestContainerRunAttached(t *testing.T) { t.Run("ps stopped container with --all", func(t *testing.T) { res := c.RunDockerCmd("ps", container) - out := strings.Split(strings.TrimSpace(res.Stdout()), "\n") + out := lines(res.Stdout()) assert.Assert(t, is.Len(out, 1)) res = c.RunDockerCmd("ps", "--all", container) - out = strings.Split(strings.TrimSpace(res.Stdout()), "\n") + out = lines(res.Stdout()) assert.Assert(t, is.Len(out, 2)) }) @@ -415,7 +464,7 @@ func TestComposeUpUpdate(t *testing.T) { // Name of Compose project is taken from current folder "acie2e" c.RunDockerCmd("compose", "up", "-f", composeFile) res := c.RunDockerCmd("ps") - out := strings.Split(strings.TrimSpace(res.Stdout()), "\n") + out := lines(res.Stdout()) // Check three containers are running assert.Assert(t, is.Len(out, 4)) webRunning := false @@ -444,7 +493,7 @@ func TestComposeUpUpdate(t *testing.T) { t.Run("compose ps", func(t *testing.T) { res := c.RunDockerCmd("compose", "ps", "--project-name", composeProjectName) - lines := strings.Split(strings.TrimSpace(res.Stdout()), "\n") + lines := lines(res.Stdout()) assert.Assert(t, is.Len(lines, 4)) var wordsDisplayed, webDisplayed, dbDisplayed bool for _, line := range lines { @@ -468,7 +517,7 @@ func TestComposeUpUpdate(t *testing.T) { t.Run("compose ls", func(t *testing.T) { res := c.RunDockerCmd("compose", "ls") - lines := strings.Split(strings.TrimSpace(res.Stdout()), "\n") + lines := lines(res.Stdout()) assert.Equal(t, 2, len(lines)) fields := strings.Fields(lines[1]) @@ -485,7 +534,7 @@ func TestComposeUpUpdate(t *testing.T) { t.Run("update", func(t *testing.T) { c.RunDockerCmd("compose", "up", "-f", composeFileMultiplePorts, "--project-name", composeProjectName) res := c.RunDockerCmd("ps") - out := strings.Split(strings.TrimSpace(res.Stdout()), "\n") + out := lines(res.Stdout()) // Check three containers are running assert.Assert(t, is.Len(out, 4)) @@ -527,7 +576,7 @@ func TestComposeUpUpdate(t *testing.T) { t.Run("down", func(t *testing.T) { c.RunDockerCmd("compose", "down", "--project-name", composeProjectName) res := c.RunDockerCmd("ps") - out := strings.Split(strings.TrimSpace(res.Stdout()), "\n") + out := lines(res.Stdout()) assert.Equal(t, len(out), 1) }) } @@ -548,7 +597,7 @@ func TestRunEnvVars(t *testing.T) { cmd.Env = append(cmd.Env, "MYSQL_USER=user1") res := icmd.RunCmd(cmd) res.Assert(t, icmd.Success) - out := strings.Split(strings.TrimSpace(res.Stdout()), "\n") + out := lines(res.Stdout()) container := strings.TrimSpace(out[len(out)-1]) res = c.RunDockerCmd("inspect", container) @@ -583,7 +632,7 @@ func setupTestResourceGroup(t *testing.T, c *E2eCLI) (string, string) { createAciContextAndUseIt(t, c, sID, rg) // Check nothing is running res := c.RunDockerCmd("ps") - assert.Assert(t, is.Len(strings.Split(strings.TrimSpace(res.Stdout()), "\n"), 1)) + assert.Assert(t, is.Len(lines(res.Stdout()), 1)) return sID, rg } @@ -637,37 +686,6 @@ func createAciContextAndUseIt(t *testing.T, c *E2eCLI, sID, rgName string) { res.Assert(t, icmd.Expected{Out: contextName + " *"}) } -func createStorageAccount(t *testing.T, aciContext store.AciContext, name string) (azure_storage.Account, func() error) { - account, err := storage.CreateStorageAccount(context.TODO(), aciContext, name) - assert.Check(t, is.Nil(err)) - assert.Check(t, is.Equal(*(account.Name), name)) - return account, func() error { return deleteStorageAccount(aciContext, name) } -} - -func deleteStorageAccount(aciContext store.AciContext, name string) error { - _, err := storage.DeleteStorageAccount(context.TODO(), aciContext, name) - return err -} - -func getStorageKeys(t *testing.T, aciContext store.AciContext, saName string) []azure_storage.AccountKey { - l, err := storage.ListKeys(context.TODO(), aciContext, saName) - assert.NilError(t, err) - assert.Assert(t, l.Keys != nil) - return *l.Keys -} - -func createFileShare(t *testing.T, key, share, storageAccount string) (*azfile.SharedKeyCredential, *url.URL) { - // Create a ShareURL object that wraps a soon-to-be-created share's URL and a default pipeline. - u, _ := url.Parse(fmt.Sprintf("https://%s.file.core.windows.net/%s", storageAccount, share)) - cred, err := azfile.NewSharedKeyCredential(storageAccount, key) - assert.NilError(t, err) - - shareURL := azfile.NewShareURL(*u, azfile.NewPipeline(cred, azfile.PipelineOptions{})) - _, err = shareURL.Create(context.TODO(), azfile.Metadata{}, 0) - assert.NilError(t, err) - return cred, u -} - func uploadFile(t *testing.T, cred azfile.SharedKeyCredential, baseURL, fileName, content string) { fURL, err := url.Parse(baseURL + "/" + fileName) assert.NilError(t, err) @@ -677,7 +695,7 @@ func uploadFile(t *testing.T, cred azfile.SharedKeyCredential, baseURL, fileName } func getContainerName(stdout string) string { - out := strings.Split(strings.TrimSpace(stdout), "\n") + out := lines(stdout) return strings.TrimSpace(out[len(out)-1]) } diff --git a/tests/aci-e2e/storage/storage.go b/tests/aci-e2e/storage/storage.go deleted file mode 100644 index 77eff6f04..000000000 --- a/tests/aci-e2e/storage/storage.go +++ /dev/null @@ -1,93 +0,0 @@ -/* - Copyright 2020 Docker, Inc. - - 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 storage - -import ( - "context" - "errors" - - "github.com/Azure/azure-sdk-for-go/profiles/2019-03-01/storage/mgmt/storage" - "github.com/Azure/go-autorest/autorest" - "github.com/Azure/go-autorest/autorest/to" - - "github.com/docker/compose-cli/aci/login" - "github.com/docker/compose-cli/context/store" -) - -// CreateStorageAccount creates a new storage account. -func CreateStorageAccount(ctx context.Context, aciContext store.AciContext, accountName string) (storage.Account, error) { - storageAccountsClient := getStorageAccountsClient(aciContext) - result, err := storageAccountsClient.CheckNameAvailability( - ctx, - storage.AccountCheckNameAvailabilityParameters{ - Name: to.StringPtr(accountName), - Type: to.StringPtr("Microsoft.Storage/storageAccounts"), - }) - - if err != nil { - return storage.Account{}, err - } - if !*result.NameAvailable { - return storage.Account{}, errors.New("storage account name already exists" + accountName) - } - - future, err := storageAccountsClient.Create( - ctx, - aciContext.ResourceGroup, - accountName, - storage.AccountCreateParameters{ - Sku: &storage.Sku{ - Name: storage.StandardLRS, - }, - Location: to.StringPtr(aciContext.Location), - AccountPropertiesCreateParameters: &storage.AccountPropertiesCreateParameters{}}) - if err != nil { - return storage.Account{}, err - } - err = future.WaitForCompletionRef(ctx, storageAccountsClient.Client) - if err != nil { - return storage.Account{}, err - } - return future.Result(storageAccountsClient) -} - -// DeleteStorageAccount deletes a given storage account -func DeleteStorageAccount(ctx context.Context, aciContext store.AciContext, accountName string) (autorest.Response, error) { - storageAccountsClient := getStorageAccountsClient(aciContext) - response, err := storageAccountsClient.Delete(ctx, aciContext.ResourceGroup, accountName) - if err != nil { - return autorest.Response{}, err - } - return response, err -} - -// ListKeys lists the storage account keys -func ListKeys(ctx context.Context, aciContext store.AciContext, accountName string) (storage.AccountListKeysResult, error) { - storageAccountsClient := getStorageAccountsClient(aciContext) - keys, err := storageAccountsClient.ListKeys(ctx, aciContext.ResourceGroup, accountName) - if err != nil { - return storage.AccountListKeysResult{}, err - } - return keys, nil -} - -func getStorageAccountsClient(aciContext store.AciContext) storage.AccountsClient { - storageAccountsClient := storage.NewAccountsClient(aciContext.SubscriptionID) - autho, _ := login.NewAuthorizerFromLogin() - storageAccountsClient.Authorizer = autho - return storageAccountsClient -} diff --git a/tests/ecs-local-e2e/context_test.go b/tests/ecs-local-e2e/context_test.go index 6bf26bb73..4d0003ae0 100644 --- a/tests/ecs-local-e2e/context_test.go +++ b/tests/ecs-local-e2e/context_test.go @@ -21,8 +21,9 @@ import ( "os" "testing" - . "github.com/docker/compose-cli/tests/framework" "gotest.tools/v3/icmd" + + . "github.com/docker/compose-cli/tests/framework" ) const (