feat(agent): report and refuse Compose services built from source; honour --env-file (#7)

This commit is contained in:
2026-10-12 19:32:05 +02:00
parent 69ef55837e
commit 27c4200dda
8 changed files with 534 additions and 24 deletions
+113
View File
@@ -0,0 +1,113 @@
package updater
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
// OutputRunner runs "docker <args>" 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
}