add scale command

Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
This commit is contained in:
Guillaume Lours 2023-09-06 22:53:01 +02:00 committed by Nicolas De loof
parent 19bbb12fac
commit 1a98a70b8a
15 changed files with 404 additions and 1 deletions

View File

@ -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),
)

108
cmd/compose/scale.go Normal file
View File

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

View File

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

View File

@ -0,0 +1,15 @@
# docker compose alpha scale
<!---MARKER_GEN_START-->
Scale services
### Options
| Name | Type | Default | Description |
|:------------|:-----|:--------|:--------------------------------|
| `--dry-run` | | | Execute command in dry run mode |
| `--no-deps` | | | Don't start linked services. |
<!---MARKER_GEN_END-->

View File

@ -0,0 +1,15 @@
# docker compose scale
<!---MARKER_GEN_START-->
Scale services
### Options
| Name | Type | Default | Description |
|:------------|:-----|:--------|:--------------------------------|
| `--dry-run` | | | Execute command in dry run mode |
| `--no-deps` | | | Don't start linked services. |
<!---MARKER_GEN_END-->

View File

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

View File

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

View File

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

3
go.mod
View File

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

View File

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

View File

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

36
pkg/compose/scale.go Normal file
View File

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

View File

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

110
pkg/e2e/scale_test.go Normal file
View File

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

View File

@ -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()