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
+110 -1
View File
@@ -5,8 +5,12 @@ package inventory
import (
"context"
"fmt"
"log/slog"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
@@ -22,6 +26,7 @@ const (
labelService = "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"
labelSwarmTask = "com.docker.swarm.service.id"
)
@@ -35,16 +40,56 @@ type DockerClient interface {
Info(ctx context.Context) (system.Info, error)
}
// BuildCheck finds the services of a Compose project that are built from
// source. The arguments are labels of one of the project's containers.
// *updater.BuildChecker satisfies it.
type BuildCheck interface {
BuiltServices(ctx context.Context, project, workingDir, configFilesLabel, envFileLabel string) (map[string]bool, error)
}
// buildRetry is how long a failed build check is not repeated while the
// project's files stay the same.
const buildRetry = 5 * time.Minute
// Collector reads the local daemon's inventory.
type Collector struct {
cli DockerClient
selfID string
check BuildCheck
log *slog.Logger
now func() time.Time
mu sync.Mutex
cache map[string]buildEntry // by Compose project
warned map[string]bool // projects whose check failure was logged
}
// buildEntry is the cached outcome of a build check. It is valid as long as
// key (the project's labels plus its files' sizes and mtimes) is unchanged.
type buildEntry struct {
key string
built map[string]bool
err error
at time.Time
}
// stackLabels are the Compose labels a build check needs.
type stackLabels struct{ workingDir, configFiles, envFile string }
// New returns a Collector. selfID is a prefix of the agent's own container
// ID (inside a container, the hostname); that container is marked Self.
func New(cli DockerClient, selfID string) *Collector {
return &Collector{cli: cli, selfID: selfID}
return &Collector{cli: cli, selfID: selfID, now: time.Now, cache: map[string]buildEntry{}, warned: map[string]bool{}}
}
// WithBuildCheck makes the collector mark Compose services that have a
// build: section as Built. The check re-runs only when a project's files
// change. When it fails, the project's services are reported as not built and
// the failure is logged once. It returns c.
func (c *Collector) WithBuildCheck(check BuildCheck, log *slog.Logger) *Collector {
c.check, c.log = check, log
return c
}
// Hello returns the machine facts sent on connect. AgentVersion is left for
@@ -81,6 +126,7 @@ func (c *Collector) Snapshot(ctx context.Context) ([]protocol.Service, error) {
}
byKey := make(map[string]protocol.Service)
stacks := make(map[string]stackLabels)
for _, ct := range containers {
if _, isTask := ct.Labels[labelSwarmTask]; isTask {
continue
@@ -90,12 +136,19 @@ func (c *Collector) Snapshot(ctx context.Context) ([]protocol.Service, error) {
return nil, err
}
svc := c.fromContainer(ct, ref, repoDigests[ct.ImageID])
if svc.Kind == protocol.KindCompose {
if _, ok := stacks[svc.ComposeProject]; !ok {
stacks[svc.ComposeProject] = stackLabels{svc.WorkingDir, svc.ConfigFiles, ct.Labels[labelEnvFile]}
}
}
if prev, ok := byKey[svc.Key()]; ok {
svc = mergeReplicas(prev, svc)
}
byKey[svc.Key()] = svc
}
c.markBuilt(ctx, byKey, stacks)
if info.Swarm.ControlAvailable {
services, err := c.cli.ServiceList(ctx, swarm.ServiceListOptions{Status: true})
if err != nil {
@@ -216,3 +269,59 @@ func swarmStatus(st *swarm.ServiceStatus) string {
return "degraded"
}
}
// markBuilt sets Built on Compose services that have a build: section.
func (c *Collector) markBuilt(ctx context.Context, byKey map[string]protocol.Service, stacks map[string]stackLabels) {
if c.check == nil {
return
}
for key, svc := range byKey {
if svc.Kind != protocol.KindCompose {
continue
}
if c.builtServices(ctx, svc.ComposeProject, stacks[svc.ComposeProject])[svc.ComposeService] {
svc.Built = true
byKey[key] = svc
}
}
}
// builtServices returns the project's built services, from the cache while
// the project's files are unchanged.
func (c *Collector) builtServices(ctx context.Context, project string, l stackLabels) map[string]bool {
key := l.workingDir + "|" + l.configFiles + "|" + l.envFile + "|" + fileStamps(l.configFiles+","+l.envFile)
c.mu.Lock()
defer c.mu.Unlock()
if e, ok := c.cache[project]; ok && e.key == key && (e.err == nil || c.now().Sub(e.at) < buildRetry) {
return e.built
}
built, err := c.check.BuiltServices(ctx, project, l.workingDir, l.configFiles, l.envFile)
c.cache[project] = buildEntry{key: key, built: built, err: err, at: c.now()}
switch {
case err == nil:
delete(c.warned, project)
case !c.warned[project]:
c.warned[project] = true
if c.log != nil {
c.log.Warn("compose_build_check_failed", "project", project, "error", err)
}
}
return built
}
// fileStamps summarises size and mtime of the comma-separated files, so a
// changed file changes the string.
func fileStamps(list string) string {
var b strings.Builder
for _, f := range strings.Split(list, ",") {
if f = strings.TrimSpace(f); f == "" {
continue
}
if fi, err := os.Stat(f); err == nil {
fmt.Fprintf(&b, "%s:%d:%d;", f, fi.Size(), fi.ModTime().UnixNano())
} else {
fmt.Fprintf(&b, "%s:missing;", f)
}
}
return b.String()
}