feat(agent): job runner, compose override executor and swarm executor (#7)
This commit is contained in:
@@ -3,52 +3,129 @@ package updater
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/discovery"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
// ComposeExecutor invokes `docker compose` as a subprocess.
|
||||
type ComposeExecutor struct{}
|
||||
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"
|
||||
)
|
||||
|
||||
// NewComposeExecutor returns an executor that shells out to docker compose.
|
||||
func NewComposeExecutor() *ComposeExecutor {
|
||||
return &ComposeExecutor{}
|
||||
// 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
|
||||
}
|
||||
|
||||
// Execute runs `docker compose -f <file>... -p <project> pull <service>`
|
||||
// followed by `... up -d <service>`. Working directory is set to
|
||||
// job.WorkingDir so any relative paths in the compose file resolve correctly.
|
||||
func (e *ComposeExecutor) Execute(ctx context.Context, job discovery.Job) error {
|
||||
if job.Refused {
|
||||
return fmt.Errorf("refused: %s", job.RefusedReason)
|
||||
}
|
||||
if err := e.run(ctx, job, "pull"); err != nil {
|
||||
return fmt.Errorf("pull: %w", err)
|
||||
}
|
||||
if err := e.run(ctx, job, "up", "-d"); err != nil {
|
||||
return fmt.Errorf("up: %w", err)
|
||||
}
|
||||
return nil
|
||||
// CommandRunner runs "docker <args>" 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()
|
||||
}
|
||||
|
||||
func (e *ComposeExecutor) run(ctx context.Context, job discovery.Job, args ...string) error {
|
||||
cliArgs := []string{"compose"}
|
||||
for _, f := range job.ConfigFiles {
|
||||
cliArgs = append(cliArgs, "-f", f)
|
||||
}
|
||||
cliArgs = append(cliArgs, "-p", job.Project)
|
||||
cliArgs = append(cliArgs, args...)
|
||||
cliArgs = append(cliArgs, job.Service)
|
||||
// ComposeDocker is the subset of the Docker SDK ComposeExecutor needs.
|
||||
type ComposeDocker interface {
|
||||
SelfInspector
|
||||
ContainerList(ctx context.Context, opts container.ListOptions) ([]container.Summary, error)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "docker", cliArgs...)
|
||||
cmd.Dir = job.WorkingDir
|
||||
// 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
|
||||
}
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
// 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("docker %s: %w (output: %s)",
|
||||
strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||
return "", fmt.Errorf("list containers: %w", err)
|
||||
}
|
||||
return nil
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user