Merge pull request #1003 from aiordache/compose_run_cmd

Add local `compose run` command
This commit is contained in:
Guillaume Tardif 2020-12-18 10:09:04 +01:00 committed by GitHub
commit b2b9ce0a53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 380 additions and 17 deletions

View File

@ -201,3 +201,7 @@ 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) RunOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) error {
return errdefs.ErrNotImplemented
}

View File

@ -71,3 +71,7 @@ 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) RunOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) error {
return errdefs.ErrNotImplemented
}

View File

@ -18,6 +18,7 @@ package compose
import (
"context"
"io"
"github.com/compose-spec/compose-go/types"
)
@ -46,6 +47,8 @@ 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)
// RunOneOffContainer creates a service oneoff container and starts its dependencies
RunOneOffContainer(ctx context.Context, project *types.Project, opts RunOptions) error
}
// UpOptions group options of the Up API
@ -66,6 +69,16 @@ type ConvertOptions struct {
Format string
}
// RunOptions options to execute compose run
type RunOptions struct {
Name string
Command []string
Detach bool
AutoRemove bool
Writer io.Writer
Reader io.Reader
}
// PortPublisher hold status about published port
type PortPublisher struct {
URL string

View File

@ -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
}

114
cli/cmd/compose/run.go Normal file
View File

@ -0,0 +1,114 @@
/*
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"
"os"
"github.com/compose-spec/compose-go/types"
"github.com/spf13/cobra"
"github.com/docker/compose-cli/api/client"
"github.com/docker/compose-cli/api/compose"
"github.com/docker/compose-cli/progress"
)
type runOptions struct {
Name string
Command []string
WorkingDir string
ConfigPaths []string
Environment []string
Detach 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 {
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.ConfigPaths, "file", "f", []string{}, "Compose configuration files")
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")
runCmd.Flags().SetInterspersed(false)
return runCmd
}
func runRun(ctx context.Context, opts runOptions) error {
projectOpts := composeOptions{
ConfigPaths: opts.ConfigPaths,
WorkingDir: opts.WorkingDir,
Environment: opts.Environment,
}
c, project, err := setup(ctx, projectOpts, []string{opts.Name})
if err != nil {
return err
}
originalServices := project.Services
_, err = progress.Run(ctx, func(ctx context.Context) (string, error) {
return "", startDependencies(ctx, c, project, opts.Name)
})
if err != nil {
return err
}
project.Services = originalServices
// start container and attach to container streams
runOpts := compose.RunOptions{
Name: opts.Name,
Command: opts.Command,
Detach: opts.Detach,
AutoRemove: opts.Remove,
Writer: os.Stdout,
Reader: os.Stdin,
}
return c.ComposeService().RunOneOffContainer(ctx, project, runOpts)
}
func startDependencies(ctx context.Context, c *client.Client, project *types.Project, requestedService string) error {
originalServices := project.Services
dependencies := types.Services{}
for _, service := range originalServices {
if service.Name != requestedService {
dependencies = append(dependencies, service)
}
}
project.Services = dependencies
if err := c.ComposeService().Create(ctx, project); err != nil {
return err
}
if err := c.ComposeService().Start(ctx, project, nil); err != nil {
return err
}
return nil
}

View File

@ -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,6 @@ 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) RunOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) error {
return errors.Wrap(errdefs.ErrNotImplemented, "use docker-compose run")
}

29
ecs/run.go Normal file
View File

@ -0,0 +1,29 @@
/*
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) RunOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) error {
return errdefs.ErrNotImplemented
}

View File

@ -182,3 +182,6 @@ 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) RunOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) error {
return errdefs.ErrNotImplemented
}

View File

@ -120,6 +120,7 @@ func (s *composeService) getContainerStreams(ctx context.Context, container moby
Stdin: true,
Stdout: true,
Stderr: true,
Logs: true,
})
if err != nil {
return nil, nil, err

View File

@ -62,7 +62,7 @@ func (s *composeService) ensureService(ctx context.Context, project *types.Proje
number := next + i
name := fmt.Sprintf("%s_%s_%d", project.Name, service.Name, number)
eg.Go(func() error {
return s.createContainer(ctx, project, service, name, number)
return s.createContainer(ctx, project, service, name, number, false)
})
}
}
@ -163,10 +163,10 @@ func getScale(config types.ServiceConfig) int {
return 1
}
func (s *composeService) createContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, name string, number int) error {
func (s *composeService) createContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, name string, number int, autoRemove bool) error {
w := progress.ContextWriter(ctx)
w.Event(progress.CreatingEvent(name))
err := s.runContainer(ctx, project, service, name, number, nil)
err := s.createMobyContainer(ctx, project, service, name, number, nil, autoRemove)
if err != nil {
return err
}
@ -191,7 +191,7 @@ func (s *composeService) recreateContainer(ctx context.Context, project *types.P
if err != nil {
return err
}
err = s.runContainer(ctx, project, service, name, number, &container)
err = s.createMobyContainer(ctx, project, service, name, number, &container, false)
if err != nil {
return err
}
@ -228,8 +228,8 @@ func (s *composeService) restartContainer(ctx context.Context, container moby.Co
return nil
}
func (s *composeService) runContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, name string, number int, container *moby.Container) error {
containerConfig, hostConfig, networkingConfig, err := getContainerCreateOptions(project, service, number, container)
func (s *composeService) createMobyContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, name string, number int, container *moby.Container, autoRemove bool) error {
containerConfig, hostConfig, networkingConfig, err := getCreateOptions(project, service, number, container, autoRemove)
if err != nil {
return err
}

View File

@ -44,6 +44,20 @@ func (s *composeService) Create(ctx context.Context, project *types.Project) err
return err
}
if err := s.ensureProjectNetworks(ctx, project); err != nil {
return err
}
if err := s.ensureProjectVolumes(ctx, project); err != nil {
return err
}
return InDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error {
return s.ensureService(c, project, service)
})
}
func (s *composeService) ensureProjectNetworks(ctx context.Context, project *types.Project) error {
for k, network := range project.Networks {
if !network.External.External && network.Name != "" {
network.Name = fmt.Sprintf("%s_%s", project.Name, k)
@ -57,7 +71,10 @@ func (s *composeService) Create(ctx context.Context, project *types.Project) err
return err
}
}
return nil
}
func (s *composeService) ensureProjectVolumes(ctx context.Context, project *types.Project) error {
for k, volume := range project.Volumes {
if !volume.External.External && volume.Name != "" {
volume.Name = fmt.Sprintf("%s_%s", project.Name, k)
@ -71,13 +88,10 @@ func (s *composeService) Create(ctx context.Context, project *types.Project) err
return err
}
}
return InDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error {
return s.ensureService(c, project, service)
})
return nil
}
func getContainerCreateOptions(p *types.Project, s types.ServiceConfig, number int, inherit *moby.Container) (*container.Config, *container.HostConfig, *network.NetworkingConfig, error) {
func getCreateOptions(p *types.Project, s types.ServiceConfig, number int, inherit *moby.Container, autoRemove bool) (*container.Config, *container.HostConfig, *network.NetworkingConfig, error) {
hash, err := jsonHash(s)
if err != nil {
return nil, nil, nil, err
@ -88,11 +102,12 @@ func getContainerCreateOptions(p *types.Project, s types.ServiceConfig, number i
labels[k] = v
}
// TODO: change oneoffLabel value for containers started with `docker compose run`
labels[projectLabel] = p.Name
labels[serviceLabel] = s.Name
labels[versionLabel] = ComposeVersion
labels[oneoffLabel] = "False"
if _, ok := s.Labels[oneoffLabel]; !ok {
labels[oneoffLabel] = "False"
}
labels[configHashLabel] = hash
labels[workingDirLabel] = p.WorkingDir
labels[configFilesLabel] = strings.Join(p.ComposeFiles, ",")
@ -152,6 +167,7 @@ func getContainerCreateOptions(p *types.Project, s types.ServiceConfig, number i
networkMode := getNetworkMode(p, s)
hostConfig := container.HostConfig{
AutoRemove: autoRemove,
Mounts: mountOptions,
CapAdd: strslice.StrSlice(s.CapAdd),
CapDrop: strslice.StrSlice(s.CapDrop),

View File

@ -91,16 +91,17 @@ func (s *composeService) Down(ctx context.Context, projectName string, options c
func (s *composeService) removeContainers(ctx context.Context, w progress.Writer, eg *errgroup.Group, containers []moby.Container) error {
for _, container := range containers {
toDelete := container
eg.Go(func() error {
eventName := "Container " + getContainerName(container)
eventName := "Container " + getContainerName(toDelete)
w.Event(progress.StoppingEvent(eventName))
err := s.apiClient.ContainerStop(ctx, container.ID, nil)
err := s.apiClient.ContainerStop(ctx, toDelete.ID, nil)
if err != nil {
w.Event(progress.ErrorMessageEvent(eventName, "Error while Stopping"))
return err
}
w.Event(progress.RemovingEvent(eventName))
err = s.apiClient.ContainerRemove(ctx, container.ID, moby.ContainerRemoveOptions{})
err = s.apiClient.ContainerRemove(ctx, toDelete.ID, moby.ContainerRemoveOptions{})
if err != nil {
w.Event(progress.ErrorMessageEvent(eventName, "Error while Removing"))
return err

View File

@ -25,6 +25,7 @@ import (
const (
containerNumberLabel = "com.docker.compose.container-number"
oneoffLabel = "com.docker.compose.oneoff"
slugLabel = "com.docker.compose.slug"
projectLabel = "com.docker.compose.project"
volumeLabel = "com.docker.compose.volume"
workingDirLabel = "com.docker.compose.project.working_dir"

93
local/compose/run.go Normal file
View File

@ -0,0 +1,93 @@
/*
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/types"
"github.com/docker/compose-cli/api/compose"
apitypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"golang.org/x/sync/errgroup"
moby "github.com/docker/docker/pkg/stringid"
)
func (s *composeService) RunOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) error {
originalServices := project.Services
var requestedService types.ServiceConfig
for _, service := range originalServices {
if service.Name == opts.Name {
requestedService = service
}
}
project.Services = originalServices
if len(opts.Command) > 0 {
requestedService.Command = opts.Command
}
requestedService.Scale = 1
requestedService.Tty = true
requestedService.StdinOpen = true
slug := moby.GenerateRandomID()
requestedService.ContainerName = fmt.Sprintf("%s_%s_run_%s", project.Name, requestedService.Name, moby.TruncateID(slug))
requestedService.Labels = requestedService.Labels.Add(slugLabel, slug)
requestedService.Labels = requestedService.Labels.Add(oneoffLabel, "True")
if err := s.ensureImagesExists(ctx, project); err != nil { // all dependencies already checked, but might miss requestedService img
return err
}
if err := s.waitDependencies(ctx, project, requestedService); err != nil {
return err
}
if err := s.createContainer(ctx, project, requestedService, requestedService.ContainerName, 1, opts.AutoRemove); err != nil {
return err
}
containerID := requestedService.ContainerName
if opts.Detach {
err := s.apiClient.ContainerStart(ctx, containerID, apitypes.ContainerStartOptions{})
if err != nil {
return err
}
fmt.Fprintln(opts.Writer, containerID)
return nil
}
containers, err := s.apiClient.ContainerList(ctx, apitypes.ContainerListOptions{
Filters: filters.NewArgs(
filters.Arg("label", fmt.Sprintf("%s=%s", slugLabel, slug)),
),
All: true,
})
if err != nil {
return err
}
oneoffContainer := containers[0]
eg := errgroup.Group{}
eg.Go(func() error {
return s.attachContainerStreams(ctx, oneoffContainer, true, opts.Reader, opts.Writer)
})
if err = s.apiClient.ContainerStart(ctx, containerID, apitypes.ContainerStartOptions{}); err != nil {
return err
}
return eg.Wait()
}

View File

@ -103,6 +103,61 @@ func TestLocalComposeUp(t *testing.T) {
})
}
func TestLocalComposeRun(t *testing.T) {
c := NewParallelE2eCLI(t, binDir)
t.Run("compose run", func(t *testing.T) {
res := c.RunDockerCmd("compose", "run", "-f", "./fixtures/run-test/docker-compose.yml", "back")
lines := Lines(res.Stdout())
assert.Equal(t, lines[len(lines)-1], "Hello there!!", res.Stdout())
})
t.Run("check run container exited", func(t *testing.T) {
res := c.RunDockerCmd("ps", "--all")
lines := Lines(res.Stdout())
var runContainerID string
var truncatedSlug string
for _, line := range lines {
fields := strings.Fields(line)
containerID := fields[len(fields)-1]
assert.Assert(t, !strings.HasPrefix(containerID, "run-test_front"))
if strings.HasPrefix(containerID, "run-test_back") {
//only the one-off container for back service
assert.Assert(t, strings.HasPrefix(containerID, "run-test_back_run_"), containerID)
truncatedSlug = strings.Replace(containerID, "run-test_back_run_", "", 1)
runContainerID = containerID
assert.Assert(t, strings.Contains(line, "Exited"), line)
}
if strings.HasPrefix(containerID, "run-test_db_1") {
assert.Assert(t, strings.Contains(line, "Up"), line)
}
}
assert.Assert(t, runContainerID != "")
res = c.RunDockerCmd("inspect", runContainerID)
res.Assert(t, icmd.Expected{Out: `"com.docker.compose.container-number": "1"`})
res.Assert(t, icmd.Expected{Out: `"com.docker.compose.project": "run-test"`})
res.Assert(t, icmd.Expected{Out: `"com.docker.compose.oneoff": "True",`})
res.Assert(t, icmd.Expected{Out: `"com.docker.compose.slug": "` + truncatedSlug})
})
t.Run("compose run --rm", func(t *testing.T) {
res := c.RunDockerCmd("compose", "run", "-f", "./fixtures/run-test/docker-compose.yml", "--rm", "back", "/bin/sh", "-c", "echo Hello again")
lines := Lines(res.Stdout())
assert.Equal(t, lines[len(lines)-1], "Hello again", res.Stdout())
})
t.Run("check run container removed", func(t *testing.T) {
res := c.RunDockerCmd("ps", "--all")
assert.Assert(t, strings.Contains(res.Stdout(), "run-test_back"), res.Stdout())
})
t.Run("down", func(t *testing.T) {
c.RunDockerCmd("compose", "down", "-f", "./fixtures/run-test/docker-compose.yml")
res := c.RunDockerCmd("ps", "--all")
assert.Assert(t, !strings.Contains(res.Stdout(), "run-test"), res.Stdout())
})
}
func TestLocalComposeBuild(t *testing.T) {
c := NewParallelE2eCLI(t, binDir)

View File

@ -0,0 +1,24 @@
version: '3.8'
services:
back:
image: alpine
command: echo "Hello there!!"
depends_on:
- db
networks:
- backnet
db:
image: nginx
networks:
- backnet
volumes:
- data:/test
front:
image: nginx
networks:
- frontnet
networks:
frontnet:
backnet:
volumes:
data: