package updater import ( "bytes" "context" "encoding/json" "fmt" "os/exec" "strings" "time" ) // OutputRunner runs "docker " in dir and returns its stdout. A failure // carries the tail of stderr in the error. type OutputRunner func(ctx context.Context, dir string, args ...string) ([]byte, error) // ExecDockerOutput is the real OutputRunner. It uses the same minimal // environment as ExecDocker. func ExecDockerOutput(ctx context.Context, dir string, args ...string) ([]byte, error) { cmd := exec.CommandContext(ctx, "docker", args...) cmd.Dir = dir cmd.Env = dockerEnv() cmd.WaitDelay = 10 * time.Second var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { tail := &tailBuffer{max: errorOutputTail} tail.Write(stderr.Bytes()) return nil, fmt.Errorf("%w (output: %s)", err, strings.TrimSpace(tail.String())) } return stdout.Bytes(), nil } // BuildChecker finds out which services of a Compose project are built from // source, by asking `docker compose config`. Such a service has a local // image that an update would replace with the upstream one, so it is never // updated. type BuildChecker struct { cli SelfInspector selfID string run OutputRunner } // NewBuildChecker returns a BuildChecker. cli and selfID name the agent's own // container, whose mounts decide which files may be read; run is // ExecDockerOutput outside tests. func NewBuildChecker(cli SelfInspector, selfID string, run OutputRunner) *BuildChecker { return &BuildChecker{cli: cli, selfID: selfID, run: run} } // BuiltServices returns the services of project that have a build: section. // The arguments are the labels of one of the project's containers. Files that // are not inside the agent's read-write same-path mounts are refused. func (b *BuildChecker) BuiltServices(ctx context.Context, project, workingDir, configFilesLabel, envFileLabel string) (map[string]bool, error) { roots, err := MountRoots(ctx, b.cli, b.selfID) if err != nil { return nil, err } files, dir, err := stackFiles(roots, configFilesLabel) if err != nil { return nil, err } if workingDir == "" { workingDir = dir } else if !insideAny(roots, workingDir) { return nil, notMounted(workingDir) } envs, err := envFiles(roots, envFileLabel) if err != nil { return nil, err } return b.builtServices(ctx, project, workingDir, files, envs) } // builtServices runs `docker compose config --format json` on files that are // already checked. func (b *BuildChecker) builtServices(ctx context.Context, project, workingDir string, files, envs []string) (map[string]bool, error) { args := composeBase(project, workingDir, files, envs) args = append(args, "config", "--format", "json") raw, err := b.run(ctx, workingDir, args...) if err != nil { return nil, fmt.Errorf("docker compose config: %w", err) } var cfg struct { Services map[string]struct { Build json.RawMessage `json:"build"` } `json:"services"` } if err := json.Unmarshal(raw, &cfg); err != nil { return nil, fmt.Errorf("parse docker compose config: %w", err) } built := make(map[string]bool, len(cfg.Services)) for name, svc := range cfg.Services { built[name] = len(svc.Build) > 0 && string(svc.Build) != "null" } return built, nil } // composeBase returns the leading `docker compose` arguments every call // shares. --project-directory is explicit because, with -f, compose would // take it from the first file, which differs for stacks started with the // flag. func composeBase(project, workingDir string, files, envs []string) []string { args := []string{"compose", "-p", project, "--project-directory", workingDir} for _, e := range envs { args = append(args, "--env-file", e) } for _, f := range files { args = append(args, "-f", f) } return args }