diff --git a/cli/cmd/compose/compose.go b/cli/cmd/compose/compose.go index 6722527ac..9a03f4d20 100644 --- a/cli/cmd/compose/compose.go +++ b/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, diff --git a/local/e2e/compose/fixtures/build-infinite/docker-compose.yml b/local/e2e/compose/fixtures/build-infinite/docker-compose.yml new file mode 100644 index 000000000..cdc1e1693 --- /dev/null +++ b/local/e2e/compose/fixtures/build-infinite/docker-compose.yml @@ -0,0 +1,3 @@ +services: + service1: + build: service1 \ No newline at end of file diff --git a/local/e2e/compose/fixtures/build-infinite/service1/Dockerfile b/local/e2e/compose/fixtures/build-infinite/service1/Dockerfile new file mode 100644 index 000000000..3fd64e7b6 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/local/e2e/compose/metrics_test.go b/local/e2e/compose/metrics_test.go index 1600d6ea3..a760bb1f6 100644 --- a/local/e2e/compose/metrics_test.go +++ b/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 +} diff --git a/utils/e2e/framework.go b/utils/e2e/framework.go index a194f5049..f6fca2d24 100644 --- a/utils/e2e/framework.go +++ b/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")