package updater import ( "context" "fmt" "io" "os/exec" "strings" "time" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/shcizo/package-updater/internal/protocol" ) const ( labelComposeProject = "com.docker.compose.project" labelComposeService = "com.docker.compose.service" labelWorkingDir = "com.docker.compose.project.working_dir" labelConfigFiles = "com.docker.compose.project.config_files" ) // errorOutputTail is how much of a failed command's output goes into the // job's error message; the full output is in the job log. const errorOutputTail = 2 << 10 // tailWriter adapts a tailBuffer to io.Writer. type tailWriter struct{ b *tailBuffer } func (w tailWriter) Write(p []byte) (int, error) { w.b.Write(p) return len(p), nil } // CommandRunner runs "docker " in dir with stdout and stderr to out. type CommandRunner func(ctx context.Context, dir string, out io.Writer, args ...string) error // ExecDocker is the real CommandRunner. When ctx ends the process is killed // and, after a grace period, its output pipes are closed. func ExecDocker(ctx context.Context, dir string, out io.Writer, args ...string) error { cmd := exec.CommandContext(ctx, "docker", args...) cmd.Dir = dir cmd.Stdout = out cmd.Stderr = out cmd.WaitDelay = 10 * time.Second return cmd.Run() } // ComposeDocker is the subset of the Docker SDK ComposeExecutor needs. type ComposeDocker interface { SelfInspector ContainerList(ctx context.Context, opts container.ListOptions) ([]container.Summary, error) } // ComposeExecutor updates a Compose service by writing the agent-owned // override file and running `docker compose pull` and `up -d`. type ComposeExecutor struct { cli ComposeDocker resolve *Resolver selfID string run CommandRunner } // NewComposeExecutor returns an executor for Compose jobs. selfID names the // agent's own container, whose mounts decide which directories may be // touched; run is ExecDocker outside tests. func NewComposeExecutor(cli ComposeDocker, resolve *Resolver, selfID string, run CommandRunner) *ComposeExecutor { return &ComposeExecutor{cli: cli, resolve: resolve, selfID: selfID, run: run} } // Execute pins job.Ref to its current digest, writes it to the stack's // override file and recreates the service. Working directory and config // files come from the service's container labels on this machine, never from // the job. It returns the pinned ref. func (e *ComposeExecutor) Execute(ctx context.Context, job protocol.JobStart, out io.Writer) (string, error) { project, service := job.ComposeProject, job.ComposeService cts, err := e.cli.ContainerList(ctx, container.ListOptions{ All: true, Filters: filters.NewArgs( filters.Arg("label", labelComposeProject+"="+project), filters.Arg("label", labelComposeService+"="+service), ), }) if err != nil { return "", fmt.Errorf("list containers: %w", err) } if len(cts) == 0 { return "", fmt.Errorf("no container for compose service %s/%s on this machine", project, service) } workingDir := cts[0].Labels[labelWorkingDir] configFiles := cts[0].Labels[labelConfigFiles] roots, err := MountRoots(ctx, e.cli, e.selfID) if err != nil { return "", err } files, dir, err := composeFiles(roots, configFiles) if err != nil { return "", err } if workingDir == "" { workingDir = dir } else if !insideAny(roots, workingDir) { return "", notMounted(workingDir) } pinned, err := e.resolve.Resolve(ctx, job.Ref) if err != nil { return "", err } fmt.Fprintf(out, "resolved %s\n", pinned) if _, err := WriteOverride(dir, service, pinned); err != nil { return "", err } base := []string{"compose", "-p", project} for _, f := range files { base = append(base, "-f", f) } for _, step := range [][]string{{"pull", service}, {"up", "-d", service}} { args := append(append([]string(nil), base...), step...) fmt.Fprintf(out, "$ docker %s\n", strings.Join(args, " ")) tail := &tailBuffer{max: errorOutputTail} if err := e.run(ctx, workingDir, io.MultiWriter(out, tailWriter{tail}), args...); err != nil { return "", fmt.Errorf("docker compose %s: %w (output: %s)", step[0], err, strings.TrimSpace(tail.String())) } } return pinned, nil }