v0.233.0: record what each compose service actually installed, and badge whether it is current
gates / gates (push) Successful in 12s

Update arc slices 1 and 2. NEITHER CHANGES ANY BEHAVIOUR — no new endpoint, no
auto-update, the three lifecycle buttons byte-identical.

Slice 1 — app.yaml gains installed_images, keyed by compose SERVICE name, each
entry carrying ref + repo digest + first-seen timestamp. Written by
Manager.recordInstalledImages after a successful compose up from StartStack,
RestartStack, UpdateStack and runComposeDeploy. Read from the CONTAINER, never
from docker-compose.yml: the syncer overwrites a deployed app's compose on a
15-minute cycle and the two disagreed for 25 minutes in the spike's own
measurement. A failed write NEVER refuses the action - the deliberate opposite
of SetDesiredState, because this is an observation and that is an intent. Not
called from StartStackServices (the R-47 DB-only window). Its own docker seam
with a context and a 30s timeout, which neither existing exec helper has.

Slice 2 — .felhom.yml gains optional catalog_since; web.updateBadge compares the
recorded ref per service against what the current template pins and returns a
*MetaBadge through the EXISTING meta_badge partial. No new markup, no new CSS.
NO RECORD RENDERS NOTHING: absent means unknown and never means current. No
version number reaches the customer and no registry is queried.

Known limitation, filed not hidden: 23 catalog pins float, so those apps can read
Naprakesz when the image behind the tag has moved.

+17 tests (1707 -> 1724), 28 packages green. Wiring proven through a real
RestartStack plus an AST walk of the four call sites. Three companion red-proofs
run and reverted.
This commit is contained in:
2026-09-02 20:18:01 +02:00
parent 960d29b061
commit 8025304acc
14 changed files with 1637 additions and 9 deletions
+39 -1
View File
@@ -121,6 +121,39 @@ type AppConfig struct {
// the primitive would make a nightly backup indistinguishable from the customer pressing Stop,
// which is the exact confusion this field exists to end. Writers: SetDesiredState's callers.
DesiredState string `yaml:"desired_state,omitempty" json:"desired_state,omitempty"`
// InstalledImages records what each compose service is ACTUALLY RUNNING, read from the
// containers after a successful compose up — never from docker-compose.yml, which the catalog
// syncer overwrites on a 15-minute cycle with no deployed check at all (measured live:
// SPIKE-app-update-2026-09-01 §3, where the file said v2.8.5 while the container ran v2.8.6 for
// 25 minutes). The file is the value that has already moved; the container is the fact.
//
// Keyed by COMPOSE SERVICE NAME, not container name: the service name is what the compose file
// and the catalog template both key on, so it is the only key a comparison can be made against.
//
// ABSENT MEANS UNKNOWN AND NEVER MEANS CURRENT (the R-166 rule, applied to an observation
// instead of an intent). Every app.yaml written before v0.233.0 has no entry here, so absent is
// the common value on upgrade; a reader that treated it as "up to date" would tell every
// customer on the fleet that their months-old app is current.
//
// WRITTEN BY: Manager.recordInstalledImages ONLY, from StartStack / RestartStack / UpdateStack
// and the deploy path. READ BY: web.updateBadge (v0.233.0). Nothing takes a DECISION from it.
InstalledImages map[string]InstalledImage `yaml:"installed_images,omitempty" json:"installed_images,omitempty"`
}
// InstalledImage is one compose service's observed image. See AppConfig.InstalledImages.
type InstalledImage struct {
// Ref is the reference the container was created FROM, i.e. docker inspect .Config.Image —
// e.g. "lscr.io/linuxserver/bookstack:26.05.2". This is what the template pins and what the
// comparison uses.
Ref string `yaml:"ref" json:"ref"`
// Digest is the repo digest of the image behind that reference — the only identifier that
// cannot move. Empty for an image that was never pulled from a registry (a locally built or
// imported image has no RepoDigests); an empty digest is recorded, never a skipped entry.
Digest string `yaml:"digest,omitempty" json:"digest,omitempty"`
// At is RFC3339 UTC: when this exact Ref+Digest pair was FIRST observed for this service. It is
// deliberately NOT re-stamped on every restart — an unchanged observation must not rewrite
// app.yaml (the SetDesiredState rule), and "running since" is more useful than "last looked at".
At string `yaml:"at" json:"at"`
}
// DeployRequest contains the user-provided values from the deploy form.
@@ -443,8 +476,13 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string,
}
m.mu.Unlock()
// Post-deploy container state check (async, non-blocking)
// Record what this deploy actually installed, per compose service (v0.233.0). Runs AFTER the
// SaveAppConfig above so it loads an app.yaml that already reads deployed=true. A failure here
// never fails the deploy — see recordInstalledImages.
deployEnv := m.stackEnv(stackDir)
m.recordInstalledImages(name, stackDir, deployEnv)
// Post-deploy container state check (async, non-blocking)
m.logPostStartStatus(name, stackDir, deployEnv)
_ = m.RefreshStatus()