diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 9430dcf9d..cd6155ad2 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -476,6 +476,7 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { // createCommand(&opts, dockerCli, backend), copyCommand(&opts, dockerCli, backend), waitCommand(&opts, dockerCli, backend), + scaleCommand(&opts, dockerCli, backend), alphaCommand(&opts, dockerCli, backend), ) diff --git a/cmd/compose/scale.go b/cmd/compose/scale.go new file mode 100644 index 000000000..97b95e65c --- /dev/null +++ b/cmd/compose/scale.go @@ -0,0 +1,108 @@ +/* + 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" + "strconv" + "strings" + + "github.com/docker/cli/cli/command" + + "github.com/compose-spec/compose-go/types" + "github.com/pkg/errors" + "golang.org/x/exp/maps" + + "github.com/docker/compose/v2/pkg/api" + "github.com/spf13/cobra" +) + +type scaleOptions struct { + *ProjectOptions + noDeps bool +} + +func scaleCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) *cobra.Command { + opts := scaleOptions{ + ProjectOptions: p, + } + scaleCmd := &cobra.Command{ + Use: "scale [SERVICE=REPLICAS...]", + Short: "Scale services ", + Args: cobra.MinimumNArgs(1), + RunE: Adapt(func(ctx context.Context, args []string) error { + serviceTuples, err := parseServicesReplicasArgs(args) + if err != nil { + return err + } + return runScale(ctx, dockerCli, backend, opts, serviceTuples) + }), + ValidArgsFunction: completeServiceNames(dockerCli, p), + } + flags := scaleCmd.Flags() + flags.BoolVar(&opts.noDeps, "no-deps", false, "Don't start linked services.") + + return scaleCmd +} + +func runScale(ctx context.Context, dockerCli command.Cli, backend api.Service, opts scaleOptions, serviceReplicaTuples map[string]int) error { + services := maps.Keys(serviceReplicaTuples) + project, err := opts.ToProject(dockerCli, services) + if err != nil { + return err + } + + if opts.noDeps { + if err := project.ForServices(services, types.IgnoreDependencies); err != nil { + return err + } + } + + for key, value := range serviceReplicaTuples { + for i, service := range project.Services { + if service.Name != key { + continue + } + if service.Deploy == nil { + service.Deploy = &types.DeployConfig{} + } + scale := uint64(value) + service.Deploy.Replicas = &scale + project.Services[i] = service + break + } + } + + return backend.Scale(ctx, project, api.ScaleOptions{Services: services}) +} + +func parseServicesReplicasArgs(args []string) (map[string]int, error) { + serviceReplicaTuples := map[string]int{} + for _, arg := range args { + key, val, ok := strings.Cut(arg, "=") + if !ok || key == "" || val == "" { + return nil, errors.Errorf("Invalide scale specifier %q.", arg) + } + intValue, err := strconv.Atoi(val) + + if err != nil { + return nil, errors.Errorf("Invalide scale specifier, can't parse replicate value to int %q.", arg) + } + serviceReplicaTuples[key] = intValue + } + return serviceReplicaTuples, nil +} diff --git a/docs/reference/compose.md b/docs/reference/compose.md index fa07fd921..643b03654 100644 --- a/docs/reference/compose.md +++ b/docs/reference/compose.md @@ -26,6 +26,7 @@ Define and run multi-container applications with Docker. | [`restart`](compose_restart.md) | Restart service containers | | [`rm`](compose_rm.md) | Removes stopped service containers | | [`run`](compose_run.md) | Run a one-off command on a service. | +| [`scale`](compose_scale.md) | Scale services | | [`start`](compose_start.md) | Start services | | [`stop`](compose_stop.md) | Stop services | | [`top`](compose_top.md) | Display the running processes | diff --git a/docs/reference/compose_alpha_scale.md b/docs/reference/compose_alpha_scale.md new file mode 100644 index 000000000..3cda50e20 --- /dev/null +++ b/docs/reference/compose_alpha_scale.md @@ -0,0 +1,15 @@ +# docker compose alpha scale + + +Scale services + +### Options + +| Name | Type | Default | Description | +|:------------|:-----|:--------|:--------------------------------| +| `--dry-run` | | | Execute command in dry run mode | +| `--no-deps` | | | Don't start linked services. | + + + + diff --git a/docs/reference/compose_scale.md b/docs/reference/compose_scale.md new file mode 100644 index 000000000..5cf5830e2 --- /dev/null +++ b/docs/reference/compose_scale.md @@ -0,0 +1,15 @@ +# docker compose scale + + +Scale services + +### Options + +| Name | Type | Default | Description | +|:------------|:-----|:--------|:--------------------------------| +| `--dry-run` | | | Execute command in dry run mode | +| `--no-deps` | | | Don't start linked services. | + + + + diff --git a/docs/reference/docker_compose.yaml b/docs/reference/docker_compose.yaml index 4f7416676..022cf16fe 100644 --- a/docs/reference/docker_compose.yaml +++ b/docs/reference/docker_compose.yaml @@ -165,6 +165,7 @@ cname: - docker compose restart - docker compose rm - docker compose run + - docker compose scale - docker compose start - docker compose stop - docker compose top @@ -192,6 +193,7 @@ clink: - docker_compose_restart.yaml - docker_compose_rm.yaml - docker_compose_run.yaml + - docker_compose_scale.yaml - docker_compose_start.yaml - docker_compose_stop.yaml - docker_compose_top.yaml diff --git a/docs/reference/docker_compose_alpha_scale.yaml b/docs/reference/docker_compose_alpha_scale.yaml new file mode 100644 index 000000000..cc381493f --- /dev/null +++ b/docs/reference/docker_compose_alpha_scale.yaml @@ -0,0 +1,35 @@ +command: docker compose alpha scale +short: Scale services +long: Scale services +usage: docker compose alpha scale [SERVICE=REPLICAS...] +pname: docker compose alpha +plink: docker_compose_alpha.yaml +options: + - option: no-deps + value_type: bool + default_value: "false" + description: Don't start linked services. + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false +inherited_options: + - option: dry-run + value_type: bool + default_value: "false" + description: Execute command in dry run mode + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false +deprecated: false +hidden: false +experimental: false +experimentalcli: true +kubernetes: false +swarm: false + diff --git a/docs/reference/docker_compose_scale.yaml b/docs/reference/docker_compose_scale.yaml new file mode 100644 index 000000000..9391441f2 --- /dev/null +++ b/docs/reference/docker_compose_scale.yaml @@ -0,0 +1,35 @@ +command: docker compose scale +short: Scale services +long: Scale services +usage: docker compose scale [SERVICE=REPLICAS...] +pname: docker compose +plink: docker_compose.yaml +options: + - option: no-deps + value_type: bool + default_value: "false" + description: Don't start linked services. + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false +inherited_options: + - option: dry-run + value_type: bool + default_value: "false" + description: Execute command in dry run mode + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false +deprecated: false +hidden: false +experimental: false +experimentalcli: false +kubernetes: false +swarm: false + diff --git a/go.mod b/go.mod index f4be904b1..619314523 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,8 @@ require ( gotest.tools/v3 v3.5.0 ) +require golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 + require ( github.com/AdaLogics/go-fuzz-headers v0.0.0-20230106234847-43070de90fa1 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect @@ -155,7 +157,6 @@ require ( go.opentelemetry.io/otel/metric v0.37.0 // indirect go.opentelemetry.io/proto/otlp v0.19.0 // indirect golang.org/x/crypto v0.11.0 // indirect - golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect golang.org/x/mod v0.11.0 // indirect golang.org/x/net v0.12.0 // indirect golang.org/x/oauth2 v0.10.0 // indirect diff --git a/pkg/api/api.go b/pkg/api/api.go index 7f253c0a2..8bbd2026e 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -88,6 +88,12 @@ type Service interface { Viz(ctx context.Context, project *types.Project, options VizOptions) (string, error) // Wait blocks until at least one of the services' container exits Wait(ctx context.Context, projectName string, options WaitOptions) (int64, error) + // Scale manages numbers of container instances running per service + Scale(ctx context.Context, project *types.Project, options ScaleOptions) error +} + +type ScaleOptions struct { + Services []string } type WaitOptions struct { diff --git a/pkg/api/proxy.go b/pkg/api/proxy.go index 7cb40b003..b08059a32 100644 --- a/pkg/api/proxy.go +++ b/pkg/api/proxy.go @@ -56,6 +56,7 @@ type ServiceProxy struct { VizFn func(ctx context.Context, project *types.Project, options VizOptions) (string, error) WaitFn func(ctx context.Context, projectName string, options WaitOptions) (int64, error) PublishFn func(ctx context.Context, project *types.Project, repository string, options PublishOptions) error + ScaleFn func(ctx context.Context, project *types.Project, options ScaleOptions) error interceptors []Interceptor } @@ -99,6 +100,7 @@ func (s *ServiceProxy) WithService(service Service) *ServiceProxy { s.DryRunModeFn = service.DryRunMode s.VizFn = service.Viz s.WaitFn = service.Wait + s.ScaleFn = service.Scale return s } @@ -349,6 +351,13 @@ func (s *ServiceProxy) Wait(ctx context.Context, projectName string, options Wai return s.WaitFn(ctx, projectName, options) } +func (s *ServiceProxy) Scale(ctx context.Context, project *types.Project, options ScaleOptions) error { + if s.ScaleFn == nil { + return ErrNotImplemented + } + return s.ScaleFn(ctx, project, options) +} + func (s *ServiceProxy) MaxConcurrency(i int) { s.MaxConcurrencyFn(i) } diff --git a/pkg/compose/scale.go b/pkg/compose/scale.go new file mode 100644 index 000000000..c143b56d8 --- /dev/null +++ b/pkg/compose/scale.go @@ -0,0 +1,36 @@ +/* +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" + + "github.com/compose-spec/compose-go/types" + "github.com/docker/compose/v2/internal/tracing" + "github.com/docker/compose/v2/pkg/api" + "github.com/docker/compose/v2/pkg/progress" +) + +func (s *composeService) Scale(ctx context.Context, project *types.Project, options api.ScaleOptions) error { + return progress.Run(ctx, tracing.SpanWrapFunc("project/scale", tracing.ProjectOptions(project), func(ctx context.Context) error { + err := s.create(ctx, project, api.CreateOptions{Services: options.Services}) + if err != nil { + return err + } + return s.start(ctx, project.Name, api.StartOptions{Project: project, Services: options.Services}, nil) + + }), s.stdinfo()) +} diff --git a/pkg/e2e/fixtures/scale/compose.yaml b/pkg/e2e/fixtures/scale/compose.yaml new file mode 100644 index 000000000..9ff67af69 --- /dev/null +++ b/pkg/e2e/fixtures/scale/compose.yaml @@ -0,0 +1,15 @@ +services: + back: + image: nginx:alpine + depends_on: + - db + db: + image: nginx:alpine + front: + image: nginx:alpine + deploy: + replicas: 2 + dbadmin: + image: nginx:alpine + deploy: + replicas: 0 \ No newline at end of file diff --git a/pkg/e2e/scale_test.go b/pkg/e2e/scale_test.go new file mode 100644 index 000000000..c17751d96 --- /dev/null +++ b/pkg/e2e/scale_test.go @@ -0,0 +1,110 @@ +/* +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 e2e + +import ( + "fmt" + "strings" + "testing" + + testify "github.com/stretchr/testify/assert" + "gotest.tools/v3/assert" + "gotest.tools/v3/icmd" +) + +const NO_STATE_TO_CHECK = "" + +func TestScaleBasicCases(t *testing.T) { + c := NewCLI(t, WithEnv( + "COMPOSE_PROJECT_NAME=scale-basic-tests")) + + reset := func() { + c.RunDockerComposeCmd(t, "down", "--rmi", "all") + } + t.Cleanup(reset) + res := c.RunDockerComposeCmd(t, "--project-directory", "fixtures/scale", "up", "-d") + res.Assert(t, icmd.Success) + + t.Log("scale up one service") + res = c.RunDockerComposeCmd(t, "--project-directory", "fixtures/scale", "scale", "dbadmin=2") + checkServiceContainer(t, res.Combined(), "scale-basic-tests-dbadmin", "Started", 2) + + t.Log("scale up 2 services") + res = c.RunDockerComposeCmd(t, "--project-directory", "fixtures/scale", "scale", "front=3", "back=2") + checkServiceContainer(t, res.Combined(), "scale-basic-tests-front", "Running", 2) + checkServiceContainer(t, res.Combined(), "scale-basic-tests-front", "Started", 1) + checkServiceContainer(t, res.Combined(), "scale-basic-tests-back", "Running", 1) + checkServiceContainer(t, res.Combined(), "scale-basic-tests-back", "Started", 1) + + t.Log("scale down one service") + res = c.RunDockerComposeCmd(t, "--project-directory", "fixtures/scale", "scale", "dbadmin=1") + checkServiceContainer(t, res.Combined(), "scale-basic-tests-dbadmin", "Running", 1) + + t.Log("scale to 0 a service") + res = c.RunDockerComposeCmd(t, "--project-directory", "fixtures/scale", "scale", "dbadmin=0") + assert.Check(t, res.Stdout() == "", res.Stdout()) + + t.Log("scale down 2 services") + res = c.RunDockerComposeCmd(t, "--project-directory", "fixtures/scale", "scale", "front=2", "back=1") + checkServiceContainer(t, res.Combined(), "scale-basic-tests-front", "Running", 2) + assert.Check(t, !strings.Contains(res.Combined(), "Container scale-basic-tests-front-3 Running"), res.Combined()) + checkServiceContainer(t, res.Combined(), "scale-basic-tests-back", "Running", 1) +} + +func TestScaleWithDepsCases(t *testing.T) { + c := NewCLI(t, WithEnv( + "COMPOSE_PROJECT_NAME=scale-deps-tests")) + + reset := func() { + c.RunDockerComposeCmd(t, "down", "--rmi", "all") + } + t.Cleanup(reset) + res := c.RunDockerComposeCmd(t, "--project-directory", "fixtures/scale", "up", "-d", "--scale", "db=2") + res.Assert(t, icmd.Success) + + res = c.RunDockerComposeCmd(t, "ps") + checkServiceContainer(t, res.Combined(), "scale-deps-tests-db", NO_STATE_TO_CHECK, 2) + + t.Log("scale up 1 service with --no-deps") + _ = c.RunDockerComposeCmd(t, "--project-directory", "fixtures/scale", "scale", "--no-deps", "back=2") + res = c.RunDockerComposeCmd(t, "ps") + checkServiceContainer(t, res.Combined(), "scale-deps-tests-back", NO_STATE_TO_CHECK, 2) + checkServiceContainer(t, res.Combined(), "scale-deps-tests-db", NO_STATE_TO_CHECK, 2) + + t.Log("scale up 1 service without --no-deps") + _ = c.RunDockerComposeCmd(t, "--project-directory", "fixtures/scale", "scale", "back=2") + res = c.RunDockerComposeCmd(t, "ps") + checkServiceContainer(t, res.Combined(), "scale-deps-tests-back", NO_STATE_TO_CHECK, 2) + checkServiceContainer(t, res.Combined(), "scale-deps-tests-db", NO_STATE_TO_CHECK, 1) +} + +func checkServiceContainer(t *testing.T, stdout, containerName, containerState string, count int) { + found := 0 + lines := strings.Split(stdout, "\n") + for _, line := range lines { + if strings.Contains(line, containerName) && strings.Contains(line, containerState) { + found++ + } + } + if found == count { + return + } + errMessage := fmt.Sprintf("expected %d but found %d instance(s) of container %s in stoud", count, found, containerName) + if containerState != "" { + errMessage += fmt.Sprintf(" with expected state %s", containerState) + } + testify.Fail(t, errMessage, stdout) +} diff --git a/pkg/mocks/mock_docker_compose_api.go b/pkg/mocks/mock_docker_compose_api.go index 12a39a729..7eb0f7d4b 100644 --- a/pkg/mocks/mock_docker_compose_api.go +++ b/pkg/mocks/mock_docker_compose_api.go @@ -351,6 +351,20 @@ func (mr *MockServiceMockRecorder) RunOneOffContainer(ctx, project, opts interfa return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunOneOffContainer", reflect.TypeOf((*MockService)(nil).RunOneOffContainer), ctx, project, opts) } +// Scale mocks base method. +func (m *MockService) Scale(ctx context.Context, project *types.Project, options api.ScaleOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Scale", ctx, project, options) + ret0, _ := ret[0].(error) + return ret0 +} + +// Scale indicates an expected call of Scale. +func (mr *MockServiceMockRecorder) Scale(ctx, project, options interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Scale", reflect.TypeOf((*MockService)(nil).Scale), ctx, project, options) +} + // Start mocks base method. func (m *MockService) Start(ctx context.Context, projectName string, options api.StartOptions) error { m.ctrl.T.Helper()