fix(agent): list all compose profiles and fail closed on unknown services (#7)

This commit is contained in:
2026-10-12 19:38:07 +02:00
parent 27c4200dda
commit c5dba96cf8
6 changed files with 85 additions and 15 deletions
+35 -8
View File
@@ -279,7 +279,18 @@ func (c *Collector) markBuilt(ctx context.Context, byKey map[string]protocol.Ser
if svc.Kind != protocol.KindCompose {
continue
}
if c.builtServices(ctx, svc.ComposeProject, stacks[svc.ComposeProject])[svc.ComposeService] {
built := c.builtServices(ctx, svc.ComposeProject, stacks[svc.ComposeProject])
if built == nil {
continue // check failed; already logged
}
isBuilt, known := built[svc.ComposeService]
if !known {
// Fail closed: a service compose does not list cannot be shown to
// be free of build:, so it is not offered for update.
c.warnOnce(svc.ComposeProject+"/"+svc.ComposeService, "compose_service_not_in_config",
"project", svc.ComposeProject, "service", svc.ComposeService)
}
if isBuilt || !known {
svc.Built = true
byKey[key] = svc
}
@@ -289,6 +300,10 @@ func (c *Collector) markBuilt(ctx context.Context, byKey map[string]protocol.Ser
// 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 {
// The key covers the files named in the labels. Changes in files they
// pull in (extends:, include:, the default .env) do not invalidate the
// cache, so Built can be stale until another change or an agent restart.
// The job-time check is always fresh, so jobs stay safe.
key := l.workingDir + "|" + l.configFiles + "|" + l.envFile + "|" + fileStamps(l.configFiles+","+l.envFile)
c.mu.Lock()
defer c.mu.Unlock()
@@ -297,18 +312,30 @@ func (c *Collector) builtServices(ctx context.Context, project string, l stackLa
}
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:
if 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)
}
} else {
c.warnOnceLocked(project, "compose_build_check_failed", "project", project, "error", err)
}
return built
}
// warnOnce logs msg the first time it is called for key (until the next
// successful check of the project resets it).
func (c *Collector) warnOnce(key, msg string, args ...any) {
c.mu.Lock()
defer c.mu.Unlock()
c.warnOnceLocked(key, msg, args...)
}
func (c *Collector) warnOnceLocked(key, msg string, args ...any) {
if c.warned[key] || c.log == nil {
return
}
c.warned[key] = true
c.log.Warn(msg, args...)
}
// fileStamps summarises size and mtime of the comma-separated files, so a
// changed file changes the string.
func fileStamps(list string) string {
@@ -290,3 +290,22 @@ func TestSnapshot_BuildCheckFailureIsNotFatalAndRetriesLater(t *testing.T) {
assert.Equal(t, 2, check.calls)
assert.Equal(t, 1, strings.Count(logs.String(), "compose_build_check_failed"), "logged once per project")
}
func TestSnapshot_ServiceAbsentFromConfigFailsClosed(t *testing.T) {
check := &fakeBuildCheck{built: map[string]bool{"db": false}} // "web" is missing
c, _ := buildCollector(t, check, "/s/app/compose.yaml")
var logs bytes.Buffer
c.log = slog.New(slog.NewTextHandler(&logs, nil))
for range 2 {
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, "an unknown service is not offered for update")
assert.False(t, byName["db"].Built)
}
assert.Equal(t, 1, strings.Count(logs.String(), "compose_service_not_in_config"), "logged once per project")
}