Skip to content

Commit 0912c30

Browse files
authored
Merge pull request #5301 from cvas22/feat/compose-scale
compose: add scale command
2 parents 6f81902 + 1f20d61 commit 0912c30

8 files changed

Lines changed: 387 additions & 2 deletions

File tree

‎cmd/nerdctl/compose/compose.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ func Command() *cobra.Command {
6565
topCommand(),
6666
createCommand(),
6767
waitCommand(),
68+
scaleCommand(),
6869
)
6970

7071
return cmd
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package compose
18+
19+
import (
20+
"fmt"
21+
"strconv"
22+
"strings"
23+
24+
"github.com/spf13/cobra"
25+
26+
"github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers"
27+
"github.com/containerd/nerdctl/v2/pkg/clientutil"
28+
"github.com/containerd/nerdctl/v2/pkg/cmd/compose"
29+
"github.com/containerd/nerdctl/v2/pkg/composer"
30+
)
31+
32+
func scaleCommand() *cobra.Command {
33+
var cmd = &cobra.Command{
34+
Use: "scale [flags] SERVICE=REPLICAS [SERVICE=REPLICAS...]",
35+
Short: "Set the number of containers for services",
36+
Args: cobra.MinimumNArgs(1),
37+
RunE: scaleAction,
38+
SilenceUsage: true,
39+
SilenceErrors: true,
40+
}
41+
cmd.Flags().Bool("no-deps", false, "Don't start linked services")
42+
return cmd
43+
}
44+
45+
func parseScaleArgs(args []string) (map[string]int, error) {
46+
scale := make(map[string]int, len(args))
47+
for _, arg := range args {
48+
name, value, ok := strings.Cut(arg, "=")
49+
if !ok || name == "" {
50+
return nil, fmt.Errorf("invalid argument %q, should be SERVICE=REPLICAS", arg)
51+
}
52+
replicas, err := strconv.Atoi(value)
53+
if err != nil || replicas < 0 {
54+
return nil, fmt.Errorf("invalid number of replicas %q for service %q", value, name)
55+
}
56+
scale[name] = replicas
57+
}
58+
return scale, nil
59+
}
60+
61+
func scaleAction(cmd *cobra.Command, args []string) error {
62+
globalOptions, err := helpers.ProcessRootCmdFlags(cmd)
63+
if err != nil {
64+
return err
65+
}
66+
noDeps, err := cmd.Flags().GetBool("no-deps")
67+
if err != nil {
68+
return err
69+
}
70+
scale, err := parseScaleArgs(args)
71+
if err != nil {
72+
return err
73+
}
74+
75+
client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address)
76+
if err != nil {
77+
return err
78+
}
79+
defer cancel()
80+
options, err := getComposeOptions(cmd, globalOptions.DebugFull, globalOptions.Experimental)
81+
if err != nil {
82+
return err
83+
}
84+
c, err := compose.New(client, globalOptions, options, cmd.OutOrStdout(), cmd.ErrOrStderr())
85+
if err != nil {
86+
return err
87+
}
88+
return c.Scale(ctx, composer.ScaleOptions{Scale: scale, NoDeps: noDeps})
89+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package compose
18+
19+
import (
20+
"fmt"
21+
"path/filepath"
22+
"testing"
23+
24+
"gotest.tools/v3/assert"
25+
26+
"github.com/containerd/nerdctl/mod/tigron/expect"
27+
"github.com/containerd/nerdctl/mod/tigron/test"
28+
"github.com/containerd/nerdctl/mod/tigron/tig"
29+
30+
"github.com/containerd/nerdctl/v2/pkg/composer/serviceparser"
31+
"github.com/containerd/nerdctl/v2/pkg/testutil"
32+
"github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest"
33+
)
34+
35+
func TestComposeScale(t *testing.T) {
36+
testCase := nerdtest.Setup()
37+
38+
testCase.Setup = func(data test.Data, helpers test.Helpers) {
39+
dockerComposeYAML := fmt.Sprintf(`
40+
services:
41+
svc0:
42+
image: %s
43+
command: "sleep infinity"
44+
`, testutil.CommonImage)
45+
46+
composePath := data.Temp().Save(dockerComposeYAML, "compose.yaml")
47+
projectName := filepath.Base(filepath.Dir(composePath))
48+
data.Labels().Set("composeYAML", composePath)
49+
for _, n := range []string{"1", "2", "3"} {
50+
data.Labels().Set("container"+n, serviceparser.DefaultContainerName(projectName, "svc0", n))
51+
}
52+
53+
helpers.Ensure("compose", "-f", composePath, "up", "-d")
54+
nerdtest.EnsureContainerStarted(helpers, data.Labels().Get("container1"))
55+
}
56+
57+
testCase.Cleanup = func(data test.Data, helpers test.Helpers) {
58+
if data.Labels().Get("composeYAML") != "" {
59+
helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v")
60+
}
61+
}
62+
63+
testCase.SubTests = []*test.Case{
64+
{
65+
Description: "rejects an argument without replicas",
66+
NoParallel: true,
67+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
68+
return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "scale", "svc0")
69+
},
70+
Expected: test.Expects(expect.ExitCodeGenericFail, nil, nil),
71+
},
72+
{
73+
Description: "rejects an unknown service",
74+
NoParallel: true,
75+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
76+
return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "scale", "unknown=2")
77+
},
78+
Expected: test.Expects(expect.ExitCodeGenericFail, nil, nil),
79+
},
80+
{
81+
Description: "scales up",
82+
NoParallel: true,
83+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
84+
return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "scale", "svc0=3")
85+
},
86+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
87+
return &test.Expected{
88+
ExitCode: expect.ExitCodeSuccess,
89+
Output: func(stdout string, t tig.T) {
90+
for _, n := range []string{"1", "2", "3"} {
91+
nerdtest.EnsureContainerStarted(helpers, data.Labels().Get("container"+n))
92+
}
93+
},
94+
}
95+
},
96+
},
97+
{
98+
Description: "scales down",
99+
NoParallel: true,
100+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
101+
// Scaling down must keep the remaining replica as it is, not recreate it.
102+
data.Labels().Set("container1ID", helpers.Capture("container", "inspect", "--format", "{{.ID}}", data.Labels().Get("container1")))
103+
return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "scale", "svc0=1")
104+
},
105+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
106+
return &test.Expected{
107+
ExitCode: expect.ExitCodeSuccess,
108+
Output: func(stdout string, t tig.T) {
109+
id := helpers.Capture("container", "inspect", "--format", "{{.ID}}", data.Labels().Get("container1"))
110+
assert.Equal(t, data.Labels().Get("container1ID"), id)
111+
helpers.Fail("container", "inspect", data.Labels().Get("container2"))
112+
helpers.Fail("container", "inspect", data.Labels().Get("container3"))
113+
},
114+
}
115+
},
116+
},
117+
}
118+
119+
testCase.Run(t)
120+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package compose
18+
19+
import (
20+
"testing"
21+
22+
"gotest.tools/v3/assert"
23+
)
24+
25+
func TestParseScaleArgs(t *testing.T) {
26+
scale, err := parseScaleArgs([]string{"web=3", "db=0"})
27+
assert.NilError(t, err)
28+
assert.DeepEqual(t, scale, map[string]int{"web": 3, "db": 0})
29+
30+
for _, arg := range []string{"web", "=3", "web=", "web=x", "web=-1"} {
31+
_, err := parseScaleArgs([]string{arg})
32+
assert.Assert(t, err != nil, "expected an error for %q", arg)
33+
}
34+
}

‎docs/command-reference.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@
133133
- [:whale: nerdctl compose top](#whale-nerdctl-compose-top)
134134
- [:whale: nerdctl compose version](#whale-nerdctl-compose-version)
135135
- [:whale: nerdctl compose wait](#whale-nerdctl-compose-wait)
136+
- [:whale: nerdctl compose scale](#whale-nerdctl-compose-scale)
136137
- [IPFS management](#ipfs-management)
137138
- [:nerd_face: nerdctl ipfs registry serve](#nerd_face-nerdctl-ipfs-registry-serve)
138139
- [Global flags](#global-flags)
@@ -2035,6 +2036,16 @@ Flags:
20352036

20362037
- :whale: `--down-project`: Drop the project when the containers stop
20372038

2039+
### :whale: nerdctl compose scale
2040+
2041+
Set the number of containers for services. Missing replicas are created and started in the background, and replicas above the requested number are stopped and removed
2042+
2043+
Usage: `nerdctl compose scale [OPTIONS] SERVICE=REPLICAS [SERVICE=REPLICAS...]`
2044+
2045+
Flags:
2046+
2047+
- :whale: `--no-deps`: Don't start linked services
2048+
20382049
## IPFS management
20392050

20402051
P2P image distribution (IPFS) is completely optional. Your host is NOT connected to any P2P network, unless you opt in to [install and run IPFS daemon](https://docs.ipfs.io/install/).
@@ -2098,7 +2109,6 @@ Compose:
20982109
- `docker compose attach`
20992110
- `docker compose events`
21002111
- `docker compose ls`
2101-
- `docker compose scale`
21022112
- `docker compose stats`
21032113
- `docker compose watch`
21042114

‎pkg/composer/config.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ func ServiceHash(o types.ServiceConfig) (string, error) {
8383
o.PullPolicy = ""
8484
o.Scale = new(int)
8585
*(o.Scale) = 1
86+
// compose-go stores the number of replicas (including `scale` and `--scale`) in
87+
// deploy.replicas, so ignore it too: scaling a service must not recreate its containers.
88+
if o.Deploy != nil {
89+
deploy := *o.Deploy
90+
deploy.Replicas = nil
91+
o.Deploy = &deploy
92+
}
8693
bytes, err := json.Marshal(o)
8794
if err != nil {
8895
return "", err

0 commit comments

Comments
 (0)