feat(agent): report and refuse Compose services built from source; honour --env-file (#7)
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user