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