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 <guillaume.tardif@gmail.com>
This commit is contained in:
Guillaume Tardif 2021-04-22 22:05:01 +02:00
parent 0785114b90
commit de3fa40bae
5 changed files with 124 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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