(re)attach to container after restart

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2021-03-08 10:22:24 +01:00
parent 4c592700ee
commit 9c4d8ab158
No known key found for this signature in database
GPG Key ID: 9858809D6F8F6E7E
13 changed files with 199 additions and 54 deletions

View File

@ -27,6 +27,7 @@ import (
"github.com/spf13/pflag" "github.com/spf13/pflag"
"github.com/docker/compose-cli/api/context/store" "github.com/docker/compose-cli/api/context/store"
"github.com/docker/compose-cli/cli/formatter"
) )
// Warning is a global warning to be displayed to user on command failure // Warning is a global warning to be displayed to user on command failure
@ -100,11 +101,13 @@ func (o *projectOptions) toProjectOptions() (*cli.ProjectOptions, error) {
// Command returns the compose command with its child commands // Command returns the compose command with its child commands
func Command(contextType string) *cobra.Command { func Command(contextType string) *cobra.Command {
opts := projectOptions{} opts := projectOptions{}
var ansi string
command := &cobra.Command{ command := &cobra.Command{
Short: "Docker Compose", Short: "Docker Compose",
Use: "compose", Use: "compose",
TraverseChildren: true, TraverseChildren: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error { PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
formatter.SetANSIMode(ansi)
if opts.WorkDir != "" { if opts.WorkDir != "" {
if opts.ProjectDir != "" { if opts.ProjectDir != "" {
return errors.New(aec.Apply(`cannot specify DEPRECATED "--workdir" and "--project-directory". Please use only "--project-directory" instead.`, aec.RedF)) return errors.New(aec.Apply(`cannot specify DEPRECATED "--workdir" and "--project-directory". Please use only "--project-directory" instead.`, aec.RedF))
@ -149,5 +152,6 @@ func Command(contextType string) *cobra.Command {
} }
command.Flags().SetInterspersed(false) command.Flags().SetInterspersed(false)
opts.addProjectFlags(command.Flags()) opts.addProjectFlags(command.Flags())
command.Flags().StringVar(&ansi, "ansi", "auto", `Control when to print ANSI control characters ("never"|"always"|"auto")`)
return command return command
} }

View File

@ -18,7 +18,10 @@ package formatter
import ( import (
"fmt" "fmt"
"os"
"strconv" "strconv"
"github.com/mattn/go-isatty"
) )
var names = []string{ var names = []string{
@ -32,6 +35,36 @@ var names = []string{
"white", "white",
} }
const (
// Never use ANSI codes
Never = "never"
// Always use ANSI codes
Always = "always"
// Auto detect terminal is a tty and can use ANSI codes
Auto = "auto"
)
// SetANSIMode configure formatter for colored output on ANSI-compliant console
func SetANSIMode(ansi string) {
if !useAnsi(ansi) {
nextColor = func() colorFunc {
return monochrome
}
}
}
func useAnsi(ansi string) bool {
switch ansi {
case Always:
return true
case Auto:
return isatty.IsTerminal(os.Stdout.Fd())
}
return false
}
// colorFunc use ANSI codes to render colored text on console // colorFunc use ANSI codes to render colored text on console
type colorFunc func(s string) string type colorFunc func(s string) string
@ -53,6 +86,12 @@ func makeColorFunc(code string) colorFunc {
} }
} }
var nextColor func() colorFunc = rainbowColor
func rainbowColor() colorFunc {
return <-loop
}
var loop = make(chan colorFunc) var loop = make(chan colorFunc)
func init() { func init() {

View File

@ -45,7 +45,7 @@ func (l *logConsumer) Register(name string, id string) {
func (l *logConsumer) register(name string, id string) *presenter { func (l *logConsumer) register(name string, id string) *presenter {
cf := monochrome cf := monochrome
if l.color { if l.color {
cf = <-loop cf = nextColor()
} }
p := &presenter{ p := &presenter{
colors: cf, colors: cf,

View File

@ -195,7 +195,7 @@ func (e ecsLocalSimulation) UnPause(ctx context.Context, project *types.Project)
func (e ecsLocalSimulation) Top(ctx context.Context, projectName string, services []string) ([]compose.ContainerProcSummary, error) { func (e ecsLocalSimulation) Top(ctx context.Context, projectName string, services []string) ([]compose.ContainerProcSummary, error) {
return e.compose.Top(ctx, projectName, services) return e.compose.Top(ctx, projectName, services)
} }
func (e ecsLocalSimulation) Events(ctx context.Context, project string, options compose.EventsOptions) error { func (e ecsLocalSimulation) Events(ctx context.Context, project string, options compose.EventsOptions) error {
return e.compose.Events(ctx, project, options) return e.compose.Events(ctx, project, options)
} }

1
go.mod
View File

@ -39,6 +39,7 @@ require (
github.com/joho/godotenv v1.3.0 github.com/joho/godotenv v1.3.0
github.com/labstack/echo v3.3.10+incompatible github.com/labstack/echo v3.3.10+incompatible
github.com/labstack/gommon v0.3.0 // indirect github.com/labstack/gommon v0.3.0 // indirect
github.com/mattn/go-isatty v0.0.12
github.com/mattn/go-shellwords v1.0.11 github.com/mattn/go-shellwords v1.0.11
github.com/moby/buildkit v0.8.1-0.20201205083753-0af7b1b9c693 github.com/moby/buildkit v0.8.1-0.20201205083753-0af7b1b9c693
github.com/moby/term v0.0.0-20201110203204-bea5bbe245bf github.com/moby/term v0.0.0-20201110203204-bea5bbe245bf

View File

@ -258,7 +258,7 @@ func (s *composeService) UnPause(ctx context.Context, project *types.Project) er
func (s *composeService) Top(ctx context.Context, projectName string, services []string) ([]compose.ContainerProcSummary, error) { func (s *composeService) Top(ctx context.Context, projectName string, services []string) ([]compose.ContainerProcSummary, error) {
return nil, errdefs.ErrNotImplemented return nil, errdefs.ErrNotImplemented
} }
func (s *composeService) Events(ctx context.Context, project string, options compose.EventsOptions) error { func (s *composeService) Events(ctx context.Context, project string, options compose.EventsOptions) error {
return errdefs.ErrNotImplemented return errdefs.ErrNotImplemented
} }

View File

@ -31,7 +31,7 @@ import (
"github.com/docker/docker/pkg/stdcopy" "github.com/docker/docker/pkg/stdcopy"
) )
func (s *composeService) attach(ctx context.Context, project *types.Project, consumer compose.ContainerEventListener, selectedServices []string) (Containers, error) { func (s *composeService) attach(ctx context.Context, project *types.Project, listener compose.ContainerEventListener, selectedServices []string) (Containers, error) {
containers, err := s.getContainers(ctx, project, oneOffExclude, selectedServices) containers, err := s.getContainers(ctx, project, oneOffExclude, selectedServices)
if err != nil { if err != nil {
return nil, err return nil, err
@ -47,33 +47,72 @@ func (s *composeService) attach(ctx context.Context, project *types.Project, con
fmt.Printf("Attaching to %s\n", strings.Join(names, ", ")) fmt.Printf("Attaching to %s\n", strings.Join(names, ", "))
for _, container := range containers { for _, container := range containers {
consumer(compose.ContainerEvent{ err := s.attachContainer(ctx, container, listener, project)
Type: compose.ContainerEventAttach,
Source: container.ID,
Name: getContainerNameWithoutProject(container),
Service: container.Labels[serviceLabel],
})
err := s.attachContainer(ctx, container, consumer, project)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
return containers, nil
// Watch events to capture container restart and re-attach
go func() {
crashed := map[string]struct{}{}
s.Events(ctx, project.Name, compose.EventsOptions{ // nolint: errcheck
Services: selectedServices,
Consumer: func(event compose.Event) error {
if event.Status == "die" {
crashed[event.Container] = struct{}{}
return nil
}
if _, ok := crashed[event.Container]; ok {
inspect, err := s.apiClient.ContainerInspect(ctx, event.Container)
if err != nil {
return err
}
container := moby.Container{
ID: event.Container,
Names: []string{inspect.Name},
State: convert.ContainerRunning,
Labels: map[string]string{
projectLabel: project.Name,
serviceLabel: event.Service,
},
}
// Just ignore errors when reattaching to already crashed containers
s.attachContainer(ctx, container, listener, project) // nolint: errcheck
delete(crashed, event.Container)
s.waitContainer(ctx, container, listener)
}
return nil
},
})
}()
return containers, err
} }
func (s *composeService) attachContainer(ctx context.Context, container moby.Container, consumer compose.ContainerEventListener, project *types.Project) error { func (s *composeService) attachContainer(ctx context.Context, container moby.Container, listener compose.ContainerEventListener, project *types.Project) error {
serviceName := container.Labels[serviceLabel] serviceName := container.Labels[serviceLabel]
w := utils.GetWriter(getContainerNameWithoutProject(container), serviceName, container.ID, consumer) w := utils.GetWriter(getContainerNameWithoutProject(container), serviceName, container.ID, listener)
service, err := project.GetService(serviceName) service, err := project.GetService(serviceName)
if err != nil { if err != nil {
return err return err
} }
return s.attachContainerStreams(ctx, container, service.Tty, nil, w) listener(compose.ContainerEvent{
Type: compose.ContainerEventAttach,
Source: container.ID,
Name: getContainerNameWithoutProject(container),
Service: container.Labels[serviceLabel],
})
return s.attachContainerStreams(ctx, container.ID, service.Tty, nil, w)
} }
func (s *composeService) attachContainerStreams(ctx context.Context, container moby.Container, tty bool, r io.Reader, w io.Writer) error { func (s *composeService) attachContainerStreams(ctx context.Context, container string, tty bool, r io.Reader, w io.Writer) error {
stdin, stdout, err := s.getContainerStreams(ctx, container) stdin, stdout, err := s.getContainerStreams(ctx, container)
if err != nil { if err != nil {
return err return err
@ -105,32 +144,30 @@ func (s *composeService) attachContainerStreams(ctx context.Context, container m
return nil return nil
} }
func (s *composeService) getContainerStreams(ctx context.Context, container moby.Container) (io.WriteCloser, io.ReadCloser, error) { func (s *composeService) getContainerStreams(ctx context.Context, container string) (io.WriteCloser, io.ReadCloser, error) {
var stdout io.ReadCloser var stdout io.ReadCloser
var stdin io.WriteCloser var stdin io.WriteCloser
if container.State == convert.ContainerRunning { cnx, err := s.apiClient.ContainerAttach(ctx, container, moby.ContainerAttachOptions{
logs, err := s.apiClient.ContainerLogs(ctx, container.ID, moby.ContainerLogsOptions{ Stream: true,
ShowStdout: true, Stdin: true,
ShowStderr: true, Stdout: true,
Follow: true, Stderr: true,
}) Logs: false,
if err != nil { })
return nil, nil, err if err == nil {
}
stdout = logs
} else {
cnx, err := s.apiClient.ContainerAttach(ctx, container.ID, moby.ContainerAttachOptions{
Stream: true,
Stdin: true,
Stdout: true,
Stderr: true,
Logs: false,
})
if err != nil {
return nil, nil, err
}
stdout = convert.ContainerStdout{HijackedResponse: cnx} stdout = convert.ContainerStdout{HijackedResponse: cnx}
stdin = convert.ContainerStdin{HijackedResponse: cnx} stdin = convert.ContainerStdin{HijackedResponse: cnx}
return stdin, stdout, nil
} }
return stdin, stdout, nil
// Fallback to logs API
logs, err := s.apiClient.ContainerLogs(ctx, container, moby.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: true,
})
if err != nil {
return nil, nil, err
}
return stdin, logs, nil
} }

View File

@ -272,6 +272,7 @@ func (s *composeService) getCreateOptions(ctx context.Context, p *types.Project,
if err != nil { if err != nil {
return nil, nil, nil, err return nil, nil, nil, err
} }
hostConfig := container.HostConfig{ hostConfig := container.HostConfig{
AutoRemove: autoRemove, AutoRemove: autoRemove,
Binds: binds, Binds: binds,
@ -281,6 +282,7 @@ func (s *composeService) getCreateOptions(ctx context.Context, p *types.Project,
NetworkMode: networkMode, NetworkMode: networkMode,
Init: service.Init, Init: service.Init,
ReadonlyRootfs: service.ReadOnly, ReadonlyRootfs: service.ReadOnly,
RestartPolicy: getRestartPolicy(service),
// ShmSize: , TODO // ShmSize: , TODO
Sysctls: service.Sysctls, Sysctls: service.Sysctls,
PortBindings: portBindings, PortBindings: portBindings,
@ -293,6 +295,33 @@ func (s *composeService) getCreateOptions(ctx context.Context, p *types.Project,
return &containerConfig, &hostConfig, networkConfig, nil return &containerConfig, &hostConfig, networkConfig, nil
} }
func getRestartPolicy(service types.ServiceConfig) container.RestartPolicy {
var restart container.RestartPolicy
if service.Restart != "" {
split := strings.Split(service.Restart, ":")
var attempts int
if len(split) > 1 {
attempts, _ = strconv.Atoi(split[1])
}
restart = container.RestartPolicy{
Name: split[0],
MaximumRetryCount: attempts,
}
}
if service.Deploy != nil && service.Deploy.RestartPolicy != nil {
policy := *service.Deploy.RestartPolicy
var attempts int
if policy.MaxAttempts != nil {
attempts = int(*policy.MaxAttempts)
}
restart = container.RestartPolicy{
Name: policy.Condition,
MaximumRetryCount: attempts,
}
}
return restart
}
func getDeployResources(s types.ServiceConfig) container.Resources { func getDeployResources(s types.ServiceConfig) container.Resources {
resources := container.Resources{} resources := container.Resources{}
if s.Deploy == nil { if s.Deploy == nil {

View File

@ -53,8 +53,12 @@ func (s *composeService) Events(ctx context.Context, project string, options com
attributes[k] = v attributes[k] = v
} }
timestamp := time.Unix(event.Time, 0)
if event.TimeNano != 0 {
timestamp = time.Unix(0, event.TimeNano)
}
err := options.Consumer(compose.Event{ err := options.Consumer(compose.Event{
Timestamp: time.Unix(event.Time, event.TimeNano), Timestamp: timestamp,
Service: service, Service: service,
Container: event.ID, Container: event.ID,
Status: event.Status, Status: event.Status,

View File

@ -86,7 +86,7 @@ func (s *composeService) RunOneOffContainer(ctx context.Context, project *types.
return 0, err return 0, err
} }
oneoffContainer := containers[0] oneoffContainer := containers[0]
err = s.attachContainerStreams(ctx, oneoffContainer, service.Tty, opts.Reader, opts.Writer) err = s.attachContainerStreams(ctx, oneoffContainer.ID, service.Tty, opts.Reader, opts.Writer)
if err != nil { if err != nil {
return 0, err return 0, err
} }

View File

@ -22,6 +22,7 @@ import (
"github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/compose"
"github.com/compose-spec/compose-go/types" "github.com/compose-spec/compose-go/types"
moby "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -50,20 +51,25 @@ func (s *composeService) Start(ctx context.Context, project *types.Project, opti
for _, c := range containers { for _, c := range containers {
c := c c := c
go func() { go func() {
statusC, errC := s.apiClient.ContainerWait(context.Background(), c.ID, container.WaitConditionNotRunning) s.waitContainer(ctx, c, options.Attach)
select {
case status := <-statusC:
options.Attach(compose.ContainerEvent{
Type: compose.ContainerEventExit,
Source: c.ID,
Name: getCanonicalContainerName(c),
Service: c.Labels[serviceLabel],
ExitCode: int(status.StatusCode),
})
case err := <-errC:
logrus.Warnf("Unexpected API error for %s : %s\n", getCanonicalContainerName(c), err.Error())
}
}() }()
} }
return nil return nil
} }
func (s *composeService) waitContainer(ctx context.Context, c moby.Container, listener compose.ContainerEventListener) {
statusC, errC := s.apiClient.ContainerWait(ctx, c.ID, container.WaitConditionNotRunning)
name := getCanonicalContainerName(c)
select {
case status := <-statusC:
listener(compose.ContainerEvent{
Type: compose.ContainerEventExit,
Source: c.ID,
Name: name,
Service: c.Labels[serviceLabel],
ExitCode: int(status.StatusCode),
})
case err := <-errC:
logrus.Warnf("Unexpected API error for %s : %s", name, err.Error())
}
}

View File

@ -132,3 +132,17 @@ func TestComposePull(t *testing.T) {
assert.Assert(t, strings.Contains(output, "simple Pulled")) assert.Assert(t, strings.Contains(output, "simple Pulled"))
assert.Assert(t, strings.Contains(output, "another Pulled")) assert.Assert(t, strings.Contains(output, "another Pulled"))
} }
func TestAttachRestart(t *testing.T) {
c := NewParallelE2eCLI(t, binDir)
res := c.RunDockerOrExitError("compose", "--ansi=never", "--project-directory", "fixtures/attach-restart", "up")
output := res.Stdout()
assert.Assert(t, strings.Contains(output, `another_1 | world
attach-restart_another_1 exited with code 1
another_1 | world
attach-restart_another_1 exited with code 1
another_1 | world
attach-restart_another_1 exited with code 1`), res.Combined())
}

View File

@ -0,0 +1,11 @@
services:
simple:
image: busybox:1.31.0-uclibc
command: sh -c "sleep 5"
another:
image: busybox:1.31.0-uclibc
command: sh -c "sleep 0.1 && echo world && /bin/false"
deploy:
restart_policy:
condition: "on-failure"
max_attempts: 2