Files
package-updater/internal/agent/updater/compose.go
T

176 lines
5.9 KiB
Go

package updater
import (
"context"
"fmt"
"io"
"os"
"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"
labelEnvFile = "com.docker.compose.project.environment_file"
)
// 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 <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.Env = dockerEnv()
cmd.WaitDelay = 10 * time.Second
return cmd.Run()
}
// dockerEnvVars are the only variables from the agent's environment that
// reach docker compose. Compose prefers process variables over the stack's
// .env file when it interpolates, so passing the agent's own (HOSTNAME,
// AGENT_TOKEN, ...) would change stack configuration and expose the token.
var dockerEnvVars = []string{"PATH", "HOME", "DOCKER_HOST", "DOCKER_CONFIG", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH"}
// dockerEnv returns the allowed variables that are set in the agent's
// environment, as KEY=value pairs.
func dockerEnv() []string {
var env []string
for _, k := range dockerEnvVars {
if v := os.Getenv(k); v != "" {
env = append(env, k+"="+v)
}
}
return env
}
// 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
build *BuildChecker
}
// NewComposeExecutor returns an executor for Compose jobs. selfID names the
// agent's own container, whose mounts decide which directories may be
// touched; run and runOut are ExecDocker and ExecDockerOutput outside tests.
func NewComposeExecutor(cli ComposeDocker, resolve *Resolver, selfID string, run CommandRunner, runOut OutputRunner) *ComposeExecutor {
return &ComposeExecutor{cli: cli, resolve: resolve, selfID: selfID, run: run, build: NewBuildChecker(cli, selfID, runOut)}
}
// Execute pins job.Ref to its current digest (a ref that has one is used as
// is), 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]
envFileLabel := cts[0].Labels[labelEnvFile]
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)
}
envs, err := envFiles(roots, envFileLabel)
if err != nil {
return "", err
}
// Fresh check, not the inventory's cache: a build: section may have been
// added since. Nothing is written for such a service.
built, err := e.build.builtServices(ctx, project, workingDir, files[:len(files)-1], envs)
if err != nil {
return "", err
}
isBuilt, known := built[service]
if !known {
return "", fmt.Errorf("service %s not found in compose config", service)
}
if isBuilt {
return "", fmt.Errorf("service %s is built from source (build:), not updated", service)
}
pinned, err := e.resolve.Resolve(ctx, job.Ref)
if err != nil {
return "", err
}
fmt.Fprintln(out, resolveNote(job.Ref, pinned))
// Giving the override the stack's owner is best effort: a failure is only
// a warning, the update itself is fine.
_, ownerErr, err := WriteOverride(dir, service, pinned, files[0])
if err != nil {
return "", err
}
if ownerErr != nil {
fmt.Fprintf(out, "warning: could not give the override the stack's owner: %v\n", ownerErr)
}
base := composeBase(project, workingDir, files, envs)
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
}