v0.233.0: record what each compose service actually installed, and badge whether it is current
gates / gates (push) Successful in 12s
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:
@@ -0,0 +1,107 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// updateState is the three-way answer to "is this app running what the catalog currently pins?".
|
||||
//
|
||||
// THREE values, and the third is the entire safety property — the same shape, and the same lesson,
|
||||
// as AppConfig.DesiredState (R-166):
|
||||
//
|
||||
// ABSENT MEANS UNKNOWN. IT NEVER MEANS "UP TO DATE".
|
||||
//
|
||||
// Every app.yaml written before v0.233.0 carries no installed_images, so unknown is the common value
|
||||
// on upgrade. An implementation that fell through to "Naprakész" would tell every customer on the
|
||||
// fleet that their months-old app is current — a confident wrong answer, which is worse than none.
|
||||
type updateState int
|
||||
|
||||
const (
|
||||
updateUnknown updateState = iota // nothing recorded, or nothing to compare against
|
||||
updateCurrent // every service runs exactly what the template pins
|
||||
updateBehind // at least one service does not
|
||||
)
|
||||
|
||||
// compareInstalledToTemplate answers the question WITHOUT touching the network.
|
||||
//
|
||||
// NO REGISTRY QUERY, deliberately: a customer's box must not depend on reaching eight upstream
|
||||
// registries to render a page. The comparison is therefore reference-to-reference — what the
|
||||
// container was created from, against what the compose file now pins.
|
||||
//
|
||||
// KNOWN LIMITATION, stated rather than hidden (see 09-update-architecture.md and the register row):
|
||||
// 23 of the catalog's 66 distinct pins FLOAT (postgres:16-alpine, mariadb:11.6, …). For those the
|
||||
// reference can be identical while the image behind it has moved upstream — measured live in
|
||||
// SPIKE-app-update-2026-09-01 §5, where mariadb:11.4 and mariadb:12.3 had both already moved. Those
|
||||
// apps will read "Naprakész" when they may not be. Closing that needs a registry query and a digest
|
||||
// comparison, which is deferred.
|
||||
func compareInstalledToTemplate(s stacks.Stack) updateState {
|
||||
if !s.Deployed || s.Protected || s.Orphaned {
|
||||
// Not deployed: nothing is running. Protected: infra is ours, not the customer's to update.
|
||||
// Orphaned: the template is gone from the catalog, so there is nothing to be current WITH.
|
||||
return updateUnknown
|
||||
}
|
||||
if s.AppConfig == nil || len(s.AppConfig.InstalledImages) == 0 {
|
||||
return updateUnknown // legacy app.yaml — no record was ever written
|
||||
}
|
||||
if len(s.TemplateImages) == 0 {
|
||||
return updateUnknown // the compose file could not be read or pins nothing
|
||||
}
|
||||
if len(s.AppConfig.InstalledImages) != len(s.TemplateImages) {
|
||||
// A service was added or removed by the template. That IS a change the customer's running
|
||||
// stack has not taken up.
|
||||
return updateBehind
|
||||
}
|
||||
for svc, want := range s.TemplateImages {
|
||||
got, ok := s.AppConfig.InstalledImages[svc]
|
||||
if !ok || got.Ref != want {
|
||||
return updateBehind
|
||||
}
|
||||
}
|
||||
return updateCurrent
|
||||
}
|
||||
|
||||
// updateBadgeAt is the pure form: `now` is injected so the age is a testable contract rather than a
|
||||
// property of the clock. updateBadge (the funcmap entry) is the one-line wrapper.
|
||||
//
|
||||
// It returns a *MetaBadge and calls the EXISTING meta_badge partial — no new markup and no new CSS.
|
||||
// metabadge.go's own comment asks for exactly that of its second user, and this is it.
|
||||
func updateBadgeAt(s stacks.Stack, now time.Time) *MetaBadge {
|
||||
switch compareInstalledToTemplate(s) {
|
||||
case updateCurrent:
|
||||
return &MetaBadge{
|
||||
Label: "Naprakész",
|
||||
Class: "tag-ok",
|
||||
Title: "Ez az alkalmazás a legfrissebb elérhető változatot futtatja.",
|
||||
}
|
||||
case updateBehind:
|
||||
label := "Frissítés elérhető"
|
||||
if days, ok := s.Meta.CatalogSinceAge(now); ok {
|
||||
if days == 0 {
|
||||
label += " — ma"
|
||||
} else {
|
||||
label += fmt.Sprintf(" — %d napja", days)
|
||||
}
|
||||
}
|
||||
return &MetaBadge{
|
||||
Label: label,
|
||||
Class: "tag-warn",
|
||||
Title: "Újabb változat érhető el ehhez az alkalmazáshoz. " +
|
||||
"A frissítés indításához nyomd meg a Frissítés gombot.",
|
||||
}
|
||||
default:
|
||||
// UNKNOWN renders NOTHING. Not a grey "ismeretlen" pill: a badge on an app we cannot judge
|
||||
// is a question the customer cannot answer, and the record fills itself in on the next
|
||||
// restart or update anyway.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// updateBadge is the funcmap entry. NO version number appears in any string it produces — the
|
||||
// operator ruled that a household cannot act on "26.05.2", only on "you are behind, by this long".
|
||||
// Version strings stay in the logs, the API and the hub.
|
||||
//
|
||||
// It is INFORMATION ONLY. It is wired to no action, and the Frissítés button is untouched.
|
||||
func updateBadge(s stacks.Stack) *MetaBadge { return updateBadgeAt(s, time.Now().UTC()) }
|
||||
Reference in New Issue
Block a user