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()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
@@ -209,3 +216,77 @@ func TestSnapshot_ComposeConfigFilesIsRawLabel(t *testing.T) {
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "/s/a/compose.yaml,/s/a/compose.override.yaml", got[0].ConfigFiles)
|
||||
}
|
||||
|
||||
type fakeBuildCheck struct {
|
||||
built map[string]bool
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeBuildCheck) BuiltServices(context.Context, string, string, string, string) (map[string]bool, error) {
|
||||
f.calls++
|
||||
return f.built, f.err
|
||||
}
|
||||
|
||||
func buildCollector(t *testing.T, check BuildCheck, files string) (*Collector, *fakeDocker) {
|
||||
t.Helper()
|
||||
ct := composeContainer("abc", "app", "web", "app-web", "sha256:img1", "running")
|
||||
ct.Labels[labelConfigFiles] = files
|
||||
other := composeContainer("def", "app", "db", "pg:16", "sha256:img2", "running")
|
||||
other.Labels[labelConfigFiles] = files
|
||||
cli := &fakeDocker{containers: []container.Summary{ct, other}}
|
||||
return New(cli, "").WithBuildCheck(check, slog.New(slog.NewTextHandler(io.Discard, nil))), cli
|
||||
}
|
||||
|
||||
func TestSnapshot_BuiltServiceIsMarked(t *testing.T) {
|
||||
check := &fakeBuildCheck{built: map[string]bool{"web": true, "db": false}}
|
||||
c, _ := buildCollector(t, check, "/s/app/compose.yaml")
|
||||
got, err := c.Snapshot(context.Background())
|
||||
require.NoError(t, err)
|
||||
byName := map[string]protocol.Service{}
|
||||
for _, s := range got {
|
||||
byName[s.ComposeService] = s
|
||||
}
|
||||
assert.True(t, byName["web"].Built)
|
||||
assert.False(t, byName["db"].Built)
|
||||
}
|
||||
|
||||
func TestSnapshot_BuildCheckIsCachedUntilFilesChange(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "compose.yaml")
|
||||
require.NoError(t, os.WriteFile(file, []byte("services: {}\n"), 0o644))
|
||||
check := &fakeBuildCheck{built: map[string]bool{"web": true}}
|
||||
c, _ := buildCollector(t, check, file)
|
||||
|
||||
for range 3 {
|
||||
_, err := c.Snapshot(context.Background())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, 1, check.calls, "one check per project while files are unchanged")
|
||||
|
||||
require.NoError(t, os.WriteFile(file, []byte("services:\n web: {}\n"), 0o644))
|
||||
_, err := c.Snapshot(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, check.calls, "a changed file re-runs the check")
|
||||
}
|
||||
|
||||
func TestSnapshot_BuildCheckFailureIsNotFatalAndRetriesLater(t *testing.T) {
|
||||
check := &fakeBuildCheck{err: errors.New("no mounts")}
|
||||
c, _ := buildCollector(t, check, "/s/app/compose.yaml")
|
||||
now := time.Now()
|
||||
c.now = func() time.Time { return now }
|
||||
var logs bytes.Buffer
|
||||
c.log = slog.New(slog.NewTextHandler(&logs, nil))
|
||||
|
||||
got, err := c.Snapshot(context.Background())
|
||||
require.NoError(t, err)
|
||||
for _, s := range got {
|
||||
assert.False(t, s.Built)
|
||||
}
|
||||
_, _ = c.Snapshot(context.Background())
|
||||
assert.Equal(t, 1, check.calls, "a failure is not retried on every snapshot")
|
||||
|
||||
now = now.Add(buildRetry + time.Second)
|
||||
_, _ = c.Snapshot(context.Background())
|
||||
assert.Equal(t, 2, check.calls)
|
||||
assert.Equal(t, 1, strings.Count(logs.String(), "compose_build_check_failed"), "logged once per project")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user