diff --git a/aci/compose.go b/aci/compose.go index 92b5d76c2..f430ae34d 100644 --- a/aci/compose.go +++ b/aci/compose.go @@ -201,3 +201,10 @@ func (cs *aciComposeService) Logs(ctx context.Context, projectName string, consu func (cs *aciComposeService) Convert(ctx context.Context, project *types.Project, options compose.ConvertOptions) ([]byte, error) { return nil, errdefs.ErrNotImplemented } + +func (cs *aciComposeService) CreateOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) (string, error) { + return "", errdefs.ErrNotImplemented +} +func (cs *aciComposeService) Run(ctx context.Context, container string, detach bool) error { + return errdefs.ErrNotImplemented +} diff --git a/api/client/compose.go b/api/client/compose.go index aac8d56eb..5693842d5 100644 --- a/api/client/compose.go +++ b/api/client/compose.go @@ -71,3 +71,11 @@ func (c *composeService) List(context.Context, string) ([]compose.Stack, error) func (c *composeService) Convert(context.Context, *types.Project, compose.ConvertOptions) ([]byte, error) { return nil, errdefs.ErrNotImplemented } + +func (c *composeService) CreateOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) (string, error) { + return "", errdefs.ErrNotImplemented +} + +func (c *composeService) Run(ctx context.Context, container string, detach bool) error { + return errdefs.ErrNotImplemented +} diff --git a/api/compose/api.go b/api/compose/api.go index 5687cc019..1552cc697 100644 --- a/api/compose/api.go +++ b/api/compose/api.go @@ -18,6 +18,7 @@ package compose import ( "context" + "io" "github.com/compose-spec/compose-go/types" ) @@ -46,6 +47,10 @@ type Service interface { List(ctx context.Context, projectName string) ([]Stack, error) // Convert translate compose model into backend's native format Convert(ctx context.Context, project *types.Project, options ConvertOptions) ([]byte, error) + // CreateOneOffContainer creates a service oneoff container and starts its dependencies + CreateOneOffContainer(ctx context.Context, project *types.Project, opts RunOptions) (string, error) + // Run attaches to and starts a one-off container + Run(ctx context.Context, container string, detach bool) error } // UpOptions group options of the Up API @@ -66,6 +71,25 @@ type ConvertOptions struct { Format string } +// RunOptions holds all flags for compose run +type RunOptions struct { + Name string + Command []string + WorkingDir string + Environment []string + Publish []string + Labels []string + Volumes []string + Remove bool + NoDeps bool + LogConsumer LogConsumer + + Detach bool + + Stdout io.ReadCloser + Stdin io.WriteCloser +} + // PortPublisher hold status about published port type PortPublisher struct { URL string diff --git a/cli/cmd/compose/compose.go b/cli/cmd/compose/compose.go index 2881060cf..32b314d42 100644 --- a/cli/cmd/compose/compose.go +++ b/cli/cmd/compose/compose.go @@ -90,6 +90,7 @@ func Command(contextType string) *cobra.Command { listCommand(), logsCommand(), convertCommand(), + runCommand(), ) if contextType == store.LocalContextType || contextType == store.DefaultContextType { @@ -99,7 +100,7 @@ func Command(contextType string) *cobra.Command { pullCommand(), ) } - + command.Flags().SetInterspersed(false) return command } diff --git a/cli/cmd/compose/run.go b/cli/cmd/compose/run.go new file mode 100644 index 000000000..145f9fcd1 --- /dev/null +++ b/cli/cmd/compose/run.go @@ -0,0 +1,136 @@ +/* + 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 compose + +import ( + "context" + "fmt" + + "github.com/compose-spec/compose-go/cli" + "github.com/spf13/cobra" + + "github.com/docker/compose-cli/api/client" + "github.com/docker/compose-cli/api/compose" + "github.com/docker/compose-cli/api/containers" + apicontext "github.com/docker/compose-cli/context" + "github.com/docker/compose-cli/context/store" + "github.com/docker/compose-cli/progress" +) + +type runOptions struct { + Name string + Command []string + WorkingDir string + Environment []string + Detach bool + Publish []string + Labels []string + Volumes []string + NoDeps bool + Remove bool +} + +func runCommand() *cobra.Command { + opts := runOptions{} + runCmd := &cobra.Command{ + Use: "run [options] [-v VOLUME...] [-p PORT...] [-e KEY=VAL...] [-l KEY=VALUE...] SERVICE [COMMAND] [ARGS...]", + Short: "Run a one-off command on a service.", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + s := store.ContextStore(cmd.Context()) + currentCtx, err := s.Get(apicontext.CurrentContext(cmd.Context())) + if err != nil { + return err + } + switch currentCtx.Type() { + case store.DefaultContextType: + default: + return fmt.Errorf(`Command "run" is not yet implemented for %q context type`, currentCtx.Type()) + } + + if len(args) > 1 { + opts.Command = args[1:] + } + opts.Name = args[0] + return runRun(cmd.Context(), opts) + }, + } + runCmd.Flags().StringVar(&opts.WorkingDir, "workdir", "", "Work dir") + + runCmd.Flags().StringArrayVarP(&opts.Publish, "publish", "p", []string{}, "Publish a container's port(s). [HOST_PORT:]CONTAINER_PORT") + runCmd.Flags().StringVar(&opts.Name, "name", "", "Assign a name to the container") + runCmd.Flags().BoolVar(&opts.NoDeps, "no-deps", false, "Don't start linked services.") + runCmd.Flags().StringArrayVarP(&opts.Labels, "label", "l", []string{}, "Set meta data on a container") + runCmd.Flags().StringArrayVarP(&opts.Volumes, "volume", "v", []string{}, "Volume. Ex: storageaccount/my_share[:/absolute/path/to/target][:ro]") + runCmd.Flags().BoolVarP(&opts.Detach, "detach", "d", false, "Run container in background and print container ID") + runCmd.Flags().StringArrayVarP(&opts.Environment, "env", "e", []string{}, "Set environment variables") + runCmd.Flags().BoolVar(&opts.Remove, "rm", false, "Automatically remove the container when it exits") + + //addComposeCommonFlags(runCmd.Flags(), &opts.ComposeOpts) + + runCmd.Flags().SetInterspersed(false) + return runCmd +} + +func runRun(ctx context.Context, opts runOptions) error { + // target service + services := []string{opts.Name} + + projectOpts := composeOptions{} + options, err := projectOpts.toProjectOptions() + if err != nil { + return err + } + project, err := cli.ProjectFromOptions(options) + if err != nil { + return err + } + + err = filter(project, services) + if err != nil { + return err + } + + c, err := client.NewWithDefaultLocalBackend(ctx) + if err != nil { + return err + } + containerID, err := progress.Run(ctx, func(ctx context.Context) (string, error) { + return c.ComposeService().CreateOneOffContainer(ctx, project, compose.RunOptions{ + Name: opts.Name, + Command: opts.Command, + }) + }) + if err != nil { + return err + } + // start container and attach to container streams + err = c.ComposeService().Run(ctx, containerID, opts.Detach) + if err != nil { + return err + } + if opts.Detach { + fmt.Printf("%s", containerID) + return nil + } + if opts.Remove { + return c.ContainerService().Delete(ctx, containerID, containers.DeleteRequest{ + Force: true, + }) + } + return nil +} diff --git a/cli/main.go b/cli/main.go index 026af7b9f..a0883f22f 100644 --- a/cli/main.go +++ b/cli/main.go @@ -150,7 +150,8 @@ func main() { opts.AddContextFlags(root.PersistentFlags()) opts.AddConfigFlags(root.PersistentFlags()) root.Flags().BoolVarP(&opts.Version, "version", "v", false, "Print version information and quit") - + root.PersistentFlags().SetInterspersed(false) + root.Flags().SetInterspersed(false) walk(root, func(c *cobra.Command) { c.Flags().BoolP("help", "h", false, "Help for "+c.Name()) }) diff --git a/ecs/local/compose.go b/ecs/local/compose.go index 1dbaa7fe4..9dac1de81 100644 --- a/ecs/local/compose.go +++ b/ecs/local/compose.go @@ -27,6 +27,7 @@ import ( "github.com/compose-spec/compose-go/types" "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/errdefs" + "github.com/pkg/errors" "github.com/sanathkr/go-yaml" ) @@ -162,3 +163,9 @@ func (e ecsLocalSimulation) Ps(ctx context.Context, projectName string) ([]compo func (e ecsLocalSimulation) List(ctx context.Context, projectName string) ([]compose.Stack, error) { return e.compose.List(ctx, projectName) } +func (e ecsLocalSimulation) CreateOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) (string, error) { + return "", errors.Wrap(errdefs.ErrNotImplemented, "use docker-compose run") +} +func (e ecsLocalSimulation) Run(ctx context.Context, container string, detach bool) error { + return errdefs.ErrNotImplemented +} diff --git a/ecs/run.go b/ecs/run.go new file mode 100644 index 000000000..643c944c9 --- /dev/null +++ b/ecs/run.go @@ -0,0 +1,33 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package ecs + +import ( + "context" + + "github.com/compose-spec/compose-go/types" + "github.com/docker/compose-cli/api/compose" + "github.com/docker/compose-cli/errdefs" +) + +func (b *ecsAPIService) CreateOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) (string, error) { + return "", errdefs.ErrNotImplemented +} + +func (b *ecsAPIService) Run(ctx context.Context, container string, detach bool) error { + return errdefs.ErrNotImplemented +} diff --git a/example/backend.go b/example/backend.go index 9e585bd39..55a32d1d3 100644 --- a/example/backend.go +++ b/example/backend.go @@ -182,3 +182,10 @@ func (cs *composeService) Logs(ctx context.Context, projectName string, consumer func (cs *composeService) Convert(ctx context.Context, project *types.Project, options compose.ConvertOptions) ([]byte, error) { return nil, errdefs.ErrNotImplemented } +func (cs *composeService) CreateOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) (string, error) { + return "", errdefs.ErrNotImplemented +} + +func (cs *composeService) Run(ctx context.Context, container string, detach bool) error { + return errdefs.ErrNotImplemented +} diff --git a/local/compose/convergence.go b/local/compose/convergence.go index 53df364db..8128d8b7d 100644 --- a/local/compose/convergence.go +++ b/local/compose/convergence.go @@ -61,6 +61,10 @@ func (s *composeService) ensureService(ctx context.Context, project *types.Proje for i := 0; i < missing; i++ { number := next + i name := fmt.Sprintf("%s_%s_%d", project.Name, service.Name, number) + if len(service.ContainerName) > 0 { + name = service.ContainerName + } + eg.Go(func() error { return s.createContainer(ctx, project, service, name, number) }) diff --git a/local/compose/run.go b/local/compose/run.go new file mode 100644 index 000000000..4d41949ca --- /dev/null +++ b/local/compose/run.go @@ -0,0 +1,250 @@ +/* + 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 compose + +import ( + "context" + "fmt" + "io" + "os" + "sort" + + "github.com/compose-spec/compose-go/types" + "github.com/docker/compose-cli/api/compose" + convert "github.com/docker/compose-cli/local/moby" + apitypes "github.com/docker/docker/api/types" + moby "github.com/docker/docker/pkg/stringid" +) + +func (s *composeService) CreateOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) (string, error) { + name := opts.Name + service, err := project.GetService(name) + if err != nil { + return "", err + } + + err = s.ensureRequiredNetworks(ctx, project, service) + if err != nil { + return "", err + } + err = s.ensureRequiredVolumes(ctx, project, service) + if err != nil { + return "", err + } + // ensure required services are up and running before creating the oneoff container + err = s.ensureRequiredServices(ctx, project, service) + if err != nil { + return "", err + } + + //apply options to service config + updateOneOffServiceConfig(&service, project.Name, opts) + + err = s.createContainer(ctx, project, service, service.ContainerName, 1) + if err != nil { + return "", err + } + + return service.ContainerName, err +} + +func (s *composeService) Run(ctx context.Context, container string, detach bool) error { + if detach { + // start container + return s.apiClient.ContainerStart(ctx, container, apitypes.ContainerStartOptions{}) + } + + cnx, err := s.apiClient.ContainerAttach(ctx, container, apitypes.ContainerAttachOptions{ + Stream: true, + Stdin: true, + Stdout: true, + Stderr: true, + Logs: true, + }) + if err != nil { + return err + } + defer cnx.Close() + + stdout := convert.ContainerStdout{HijackedResponse: cnx} + stdin := convert.ContainerStdin{HijackedResponse: cnx} + + readChannel := make(chan error, 10) + writeChannel := make(chan error, 10) + + go func() { + _, err := io.Copy(os.Stdout, cnx.Reader) + readChannel <- err + }() + + go func() { + _, err := io.Copy(stdin, os.Stdin) + writeChannel <- err + }() + + go func() { + <-ctx.Done() + stdout.Close() //nolint:errcheck + stdin.Close() //nolint:errcheck + }() + + // start container + err = s.apiClient.ContainerStart(ctx, container, apitypes.ContainerStartOptions{}) + if err != nil { + return err + } + + for { + select { + case err := <-readChannel: + return err + case err := <-writeChannel: + return err + } + } +} + +func updateOneOffServiceConfig(service *types.ServiceConfig, projectName string, opts compose.RunOptions) { + if len(opts.Command) > 0 { + // custom command to run + service.Command = opts.Command + } + //service.Environment = opts.Environment + slug := moby.GenerateRandomID() + service.Scale = 1 + service.ContainerName = fmt.Sprintf("%s_%s_run_%s", projectName, service.Name, moby.TruncateID(slug)) + service.Labels = types.Labels{ + "com.docker.compose.slug": slug, + "com.docker.compose.oneoff": "True", + } + service.Tty = true + service.StdinOpen = true +} + +func (s *composeService) ensureRequiredServices(ctx context.Context, project *types.Project, service types.ServiceConfig) error { + requiredServices := getDependencyNames(project, service, func() []string { + return service.GetDependencies() + }) + if len(requiredServices) > 0 { + // dependencies here + services, err := project.GetServices(requiredServices) + if err != nil { + return err + } + project.Services = services + err = s.ensureImagesExists(ctx, project) + if err != nil { + return err + } + + err = InDependencyOrder(ctx, project, func(c context.Context, svc types.ServiceConfig) error { + return s.ensureService(c, project, svc) + }) + if err != nil { + return err + } + return s.Start(ctx, project, nil) + } + return nil +} + +func (s *composeService) ensureRequiredNetworks(ctx context.Context, project *types.Project, service types.ServiceConfig) error { + networks := getDependentNetworkNames(project, service) + for k, network := range project.Networks { + if !contains(networks, network.Name) { + continue + } + if !network.External.External && network.Name != "" { + network.Name = fmt.Sprintf("%s_%s", project.Name, k) + project.Networks[k] = network + } + network.Labels = network.Labels.Add(networkLabel, k) + network.Labels = network.Labels.Add(projectLabel, project.Name) + network.Labels = network.Labels.Add(versionLabel, ComposeVersion) + + err := s.ensureNetwork(ctx, network) + if err != nil { + return err + } + } + return nil +} + +func (s *composeService) ensureRequiredVolumes(ctx context.Context, project *types.Project, service types.ServiceConfig) error { + volumes := getDependentVolumeNames(project, service) + + for k, volume := range project.Volumes { + if !contains(volumes, volume.Name) { + continue + } + if !volume.External.External && volume.Name != "" { + volume.Name = fmt.Sprintf("%s_%s", project.Name, k) + project.Volumes[k] = volume + } + volume.Labels = volume.Labels.Add(volumeLabel, k) + volume.Labels = volume.Labels.Add(projectLabel, project.Name) + volume.Labels = volume.Labels.Add(versionLabel, ComposeVersion) + err := s.ensureVolume(ctx, volume) + if err != nil { + return err + } + } + return nil +} + +type filterDependency func() []string + +func getDependencyNames(project *types.Project, service types.ServiceConfig, f filterDependency) []string { + names := f() + serviceNames := service.GetDependencies() + if len(serviceNames) == 0 { + return names + } + if len(serviceNames) > 0 { + services, _ := project.GetServices(serviceNames) + for _, s := range services { + svc := getDependencyNames(project, s, f) + names = append(names, svc...) + } + } + sort.Strings(names) + return unique(names) +} + +func getDependentNetworkNames(project *types.Project, service types.ServiceConfig) []string { + return getDependencyNames(project, service, func() []string { + names := []string{} + for n := range service.Networks { + if contains(project.NetworkNames(), n) { + names = append(names, n) + } + } + return names + }) +} + +func getDependentVolumeNames(project *types.Project, service types.ServiceConfig) []string { + return getDependencyNames(project, service, func() []string { + names := []string{} + for _, v := range service.Volumes { + if contains(project.VolumeNames(), v.Source) { + names = append(names, v.Source) + } + } + return names + }) +} diff --git a/local/compose/util.go b/local/compose/util.go index dd9cbbcfe..3a025ed62 100644 --- a/local/compose/util.go +++ b/local/compose/util.go @@ -38,3 +38,14 @@ func contains(slice []string, item string) bool { } return false } + +func unique(s []string) []string { + items := []string{} + for _, item := range s { + if contains(items, item) { + continue + } + items = append(items, item) + } + return items +}