浏览代码

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

Guillaume Tardif 4 年之前
父节点
当前提交
de3fa40bae

+ 22 - 1
cli/cmd/compose/compose.go

@@ -20,7 +20,9 @@ import (
 	"context"
 	"fmt"
 	"os"
+	"os/signal"
 	"strings"
+	"syscall"
 
 	"github.com/compose-spec/compose-go/cli"
 	"github.com/compose-spec/compose-go/types"
@@ -32,6 +34,7 @@ import (
 
 	"github.com/docker/compose-cli/api/compose"
 	"github.com/docker/compose-cli/api/context/store"
+	"github.com/docker/compose-cli/api/errdefs"
 	"github.com/docker/compose-cli/cli/formatter"
 	"github.com/docker/compose-cli/cli/metrics"
 )
@@ -42,8 +45,26 @@ type Command func(context.Context, []string) error
 //Adapt a Command func to cobra library
 func Adapt(fn Command) func(cmd *cobra.Command, args []string) error {
 	return func(cmd *cobra.Command, args []string) error {
-		err := fn(cmd.Context(), args)
+		ctx := cmd.Context()
+		contextString := fmt.Sprintf("%s", ctx)
+		if !strings.HasSuffix(contextString, ".WithCancel") { // need to handle cancel
+			cancellableCtx, cancel := context.WithCancel(cmd.Context())
+			ctx = cancellableCtx
+			s := make(chan os.Signal, 1)
+			signal.Notify(s, syscall.SIGTERM, syscall.SIGINT)
+			go func() {
+				<-s
+				cancel()
+			}()
+		}
+		err := fn(ctx, args)
 		var composeErr metrics.ComposeError
+		if errdefs.IsErrCanceled(err) || errors.Is(ctx.Err(), context.Canceled) {
+			err = dockercli.StatusError{
+				StatusCode: 130,
+				Status:     metrics.CanceledStatus,
+			}
+		}
 		if errors.As(err, &composeErr) {
 			err = dockercli.StatusError{
 				StatusCode: composeErr.GetMetricsFailureCategory().ExitCode,

+ 3 - 0
local/e2e/compose/fixtures/build-infinite/docker-compose.yml

@@ -0,0 +1,3 @@
+services:
+    service1:
+        build: service1

+ 17 - 0
local/e2e/compose/fixtures/build-infinite/service1/Dockerfile

@@ -0,0 +1,17 @@
+#   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.
+
+FROM busybox
+
+RUN sleep infinity

+ 70 - 0
local/e2e/compose/metrics_test.go

@@ -17,7 +17,11 @@
 package e2e
 
 import (
+	"bytes"
 	"fmt"
+	"os/exec"
+	"strings"
+	"syscall"
 	"testing"
 	"time"
 
@@ -84,3 +88,69 @@ func TestComposeMetrics(t *testing.T) {
 		}, usage)
 	})
 }
+
+func TestComposeCancel(t *testing.T) {
+	c := NewParallelE2eCLI(t, binDir)
+	s := NewMetricsServer(c.MetricsSocket())
+	s.Start()
+	defer s.Stop()
+
+	started := false
+
+	for i := 0; i < 30; i++ {
+		c.RunDockerCmd("help", "ps")
+		if len(s.GetUsage()) > 0 {
+			started = true
+			fmt.Printf("    [%s] Server up in %d ms\n", t.Name(), i*100)
+			break
+		}
+		time.Sleep(100 * time.Millisecond)
+	}
+	assert.Assert(t, started, "Metrics mock server not available after 3 secs")
+
+	t.Run("metrics on cancel Compose build", func(t *testing.T) {
+		s.ResetUsage()
+
+		c.RunDockerCmd("compose", "ls")
+		buildProjectPath := "../compose/fixtures/build-infinite/docker-compose.yml"
+
+		// require a separate groupID from the process running tests, in order to simulate ctrl+C from a terminal.
+		// sending kill signal
+		cmd, stdout, stderr, err := StartWithNewGroupID(c.NewDockerCmd("compose", "-f", buildProjectPath, "build", "--progress", "plain"))
+		assert.NilError(t, err)
+
+		c.WaitForCondition(func() (bool, string) {
+			out := stdout.String()
+			errors := stderr.String()
+			return strings.Contains(out, "RUN sleep infinity"), fmt.Sprintf("'RUN sleep infinity' not found in : \n%s\nStderr: \n%s\n", out, errors)
+		}, 30*time.Second, 1*time.Second)
+
+		err = syscall.Kill(-cmd.Process.Pid, syscall.SIGINT) // simulate Ctrl-C : send signal to processGroup, children will have same groupId by default
+
+		assert.NilError(t, err)
+		c.WaitForCondition(func() (bool, string) {
+			out := stdout.String()
+			errors := stderr.String()
+			return strings.Contains(out, "CANCELED"), fmt.Sprintf("'CANCELED' not found in : \n%s\nStderr: \n%s\n", out, errors)
+		}, 10*time.Second, 1*time.Second)
+
+		usage := s.GetUsage()
+		assert.DeepEqual(t, []string{
+			`{"command":"compose ls","context":"moby","source":"cli","status":"success"}`,
+			`{"command":"compose build","context":"moby","source":"cli","status":"canceled"}`,
+		}, usage)
+	})
+}
+
+func StartWithNewGroupID(command icmd.Cmd) (*exec.Cmd, *bytes.Buffer, *bytes.Buffer, error) {
+	cmd := exec.Command(command.Command[0], command.Command[1:]...)
+	cmd.Env = command.Env
+	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+
+	var stdout bytes.Buffer
+	var stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	err := cmd.Start()
+	return cmd, &stdout, &stderr, err
+}

+ 12 - 0
utils/e2e/framework.go

@@ -252,6 +252,18 @@ func (c *E2eCLI) WaitForCmdResult(command icmd.Cmd, predicate func(*icmd.Result)
 	poll.WaitOn(c.test, checkStopped, poll.WithDelay(delay), poll.WithTimeout(timeout))
 }
 
+// WaitForCondition wait for predicate to execute to true
+func (c *E2eCLI) WaitForCondition(predicate func() (bool, string), timeout time.Duration, delay time.Duration) {
+	checkStopped := func(logt poll.LogT) poll.Result {
+		pass, description := predicate()
+		if !pass {
+			return poll.Continue("Condition not met: %q", description)
+		}
+		return poll.Success()
+	}
+	poll.WaitOn(c.test, checkStopped, poll.WithDelay(delay), poll.WithTimeout(timeout))
+}
+
 // PathEnvVar returns path (os sensitive) for running test
 func (c *E2eCLI) PathEnvVar() string {
 	path := c.BinDir + ":" + os.Getenv("PATH")