Skip to content
This repository was archived by the owner on Nov 27, 2023. It is now read-only.

Commit de3fa40

Browse files
committed
Handle Ctrl+C for compose CLI plugin.
Could do something nicer passing the context to the compose command, rather than intercepting it and checking if it’s “.WithCancel” or not... Signed-off-by: Guillaume Tardif <[email protected]>
1 parent 0785114 commit de3fa40

File tree

5 files changed

+124
-1
lines changed

5 files changed

+124
-1
lines changed

cli/cmd/compose/compose.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import (
2020
"context"
2121
"fmt"
2222
"os"
23+
"os/signal"
2324
"strings"
25+
"syscall"
2426

2527
"github.com/compose-spec/compose-go/cli"
2628
"github.com/compose-spec/compose-go/types"
@@ -32,6 +34,7 @@ import (
3234

3335
"github.com/docker/compose-cli/api/compose"
3436
"github.com/docker/compose-cli/api/context/store"
37+
"github.com/docker/compose-cli/api/errdefs"
3538
"github.com/docker/compose-cli/cli/formatter"
3639
"github.com/docker/compose-cli/cli/metrics"
3740
)
@@ -42,8 +45,26 @@ type Command func(context.Context, []string) error
4245
//Adapt a Command func to cobra library
4346
func Adapt(fn Command) func(cmd *cobra.Command, args []string) error {
4447
return func(cmd *cobra.Command, args []string) error {
45-
err := fn(cmd.Context(), args)
48+
ctx := cmd.Context()
49+
contextString := fmt.Sprintf("%s", ctx)
50+
if !strings.HasSuffix(contextString, ".WithCancel") { // need to handle cancel
51+
cancellableCtx, cancel := context.WithCancel(cmd.Context())
52+
ctx = cancellableCtx
53+
s := make(chan os.Signal, 1)
54+
signal.Notify(s, syscall.SIGTERM, syscall.SIGINT)
55+
go func() {
56+
<-s
57+
cancel()
58+
}()
59+
}
60+
err := fn(ctx, args)
4661
var composeErr metrics.ComposeError
62+
if errdefs.IsErrCanceled(err) || errors.Is(ctx.Err(), context.Canceled) {
63+
err = dockercli.StatusError{
64+
StatusCode: 130,
65+
Status: metrics.CanceledStatus,
66+
}
67+
}
4768
if errors.As(err, &composeErr) {
4869
err = dockercli.StatusError{
4970
StatusCode: composeErr.GetMetricsFailureCategory().ExitCode,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
services:
2+
service1:
3+
build: service1
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Copyright 2020 Docker Compose CLI authors
2+
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
FROM busybox
16+
17+
RUN sleep infinity

local/e2e/compose/metrics_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
package e2e
1818

1919
import (
20+
"bytes"
2021
"fmt"
22+
"os/exec"
23+
"strings"
24+
"syscall"
2125
"testing"
2226
"time"
2327

@@ -84,3 +88,69 @@ func TestComposeMetrics(t *testing.T) {
8488
}, usage)
8589
})
8690
}
91+
92+
func TestComposeCancel(t *testing.T) {
93+
c := NewParallelE2eCLI(t, binDir)
94+
s := NewMetricsServer(c.MetricsSocket())
95+
s.Start()
96+
defer s.Stop()
97+
98+
started := false
99+
100+
for i := 0; i < 30; i++ {
101+
c.RunDockerCmd("help", "ps")
102+
if len(s.GetUsage()) > 0 {
103+
started = true
104+
fmt.Printf(" [%s] Server up in %d ms\n", t.Name(), i*100)
105+
break
106+
}
107+
time.Sleep(100 * time.Millisecond)
108+
}
109+
assert.Assert(t, started, "Metrics mock server not available after 3 secs")
110+
111+
t.Run("metrics on cancel Compose build", func(t *testing.T) {
112+
s.ResetUsage()
113+
114+
c.RunDockerCmd("compose", "ls")
115+
buildProjectPath := "../compose/fixtures/build-infinite/docker-compose.yml"
116+
117+
// require a separate groupID from the process running tests, in order to simulate ctrl+C from a terminal.
118+
// sending kill signal
119+
cmd, stdout, stderr, err := StartWithNewGroupID(c.NewDockerCmd("compose", "-f", buildProjectPath, "build", "--progress", "plain"))
120+
assert.NilError(t, err)
121+
122+
c.WaitForCondition(func() (bool, string) {
123+
out := stdout.String()
124+
errors := stderr.String()
125+
return strings.Contains(out, "RUN sleep infinity"), fmt.Sprintf("'RUN sleep infinity' not found in : \n%s\nStderr: \n%s\n", out, errors)
126+
}, 30*time.Second, 1*time.Second)
127+
128+
err = syscall.Kill(-cmd.Process.Pid, syscall.SIGINT) // simulate Ctrl-C : send signal to processGroup, children will have same groupId by default
129+
130+
assert.NilError(t, err)
131+
c.WaitForCondition(func() (bool, string) {
132+
out := stdout.String()
133+
errors := stderr.String()
134+
return strings.Contains(out, "CANCELED"), fmt.Sprintf("'CANCELED' not found in : \n%s\nStderr: \n%s\n", out, errors)
135+
}, 10*time.Second, 1*time.Second)
136+
137+
usage := s.GetUsage()
138+
assert.DeepEqual(t, []string{
139+
`{"command":"compose ls","context":"moby","source":"cli","status":"success"}`,
140+
`{"command":"compose build","context":"moby","source":"cli","status":"canceled"}`,
141+
}, usage)
142+
})
143+
}
144+
145+
func StartWithNewGroupID(command icmd.Cmd) (*exec.Cmd, *bytes.Buffer, *bytes.Buffer, error) {
146+
cmd := exec.Command(command.Command[0], command.Command[1:]...)
147+
cmd.Env = command.Env
148+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
149+
150+
var stdout bytes.Buffer
151+
var stderr bytes.Buffer
152+
cmd.Stdout = &stdout
153+
cmd.Stderr = &stderr
154+
err := cmd.Start()
155+
return cmd, &stdout, &stderr, err
156+
}

utils/e2e/framework.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,18 @@ func (c *E2eCLI) WaitForCmdResult(command icmd.Cmd, predicate func(*icmd.Result)
252252
poll.WaitOn(c.test, checkStopped, poll.WithDelay(delay), poll.WithTimeout(timeout))
253253
}
254254

255+
// WaitForCondition wait for predicate to execute to true
256+
func (c *E2eCLI) WaitForCondition(predicate func() (bool, string), timeout time.Duration, delay time.Duration) {
257+
checkStopped := func(logt poll.LogT) poll.Result {
258+
pass, description := predicate()
259+
if !pass {
260+
return poll.Continue("Condition not met: %q", description)
261+
}
262+
return poll.Success()
263+
}
264+
poll.WaitOn(c.test, checkStopped, poll.WithDelay(delay), poll.WithTimeout(timeout))
265+
}
266+
255267
// PathEnvVar returns path (os sensitive) for running test
256268
func (c *E2eCLI) PathEnvVar() string {
257269
path := c.BinDir + ":" + os.Getenv("PATH")

0 commit comments

Comments
 (0)