8a0e0a59ad
gates / gates (push) Successful in 12s
Slice 3. R-447 was BLOCKED because R-438 established that RestartStack's use of up -d to pick up template changes was CHOSEN and written down in its own comment. The operator ruled Option 1, and this implements it. The rule: while the catalog offers the same version you run, its fixes flow to you; the moment it moves to a newer version you are frozen until you update. NOTHING was added to any of the thirteen compose up -d call sites. Most of them are repairs - the boot reconciler, the drive-return gate, the app-stop guard - and a repair path that refuses to repair leaves a customer's app down, which is worse than the problem. They are made safe by removing the reason. app.yaml gains pinned_images: what the app is SUPPOSED to run. It is NOT installed_images, which is an observation; letting a reading become a deployment is the R-166 category error one field over. Four writers, each also storing the exact definition as applied-compose.yml. UpdateStack advances the pin and re-renders BEFORE the pull, because pull and up -d act on the file on disk, and a pin set afterwards would pull the frozen version and report success. The syncer renders instead of copying, through one nil-safe seam. Catalog images equal the pin -> verbatim, so fixes and self-healing both survive; they differ -> the WHOLE stored definition, never a substitution of refs into a newer template (wger 2.6 needs a DB config the older template cannot supply). This is deliberately not 'skip deployed apps', which was option B and was rejected. AdoptPins runs once at boot after the backfill, files only, and skips loudly rather than inventing a pin. syncer.Start() moved to after it: the initial sync would otherwise run while every app was unpinned and overwrite a deployed app's version once per boot. THE BADGE HAD TO CHANGE OR SLICE 2 WOULD HAVE INVERTED SILENTLY. TemplateImages reads the LIVE compose file, which is now the frozen one, so the comparison would have answered Naprakesz on exactly the apps that are behind - with every test green, because the new field has the same type. It now reads CatalogImages. +16 tests (1729 -> 1745), 28 packages green. Three red-proofs run and reverted. A test also caught the syncer writing an empty compose file over a live app.
115 lines
5.1 KiB
Go
115 lines
5.1 KiB
Go
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 CATALOG currently offers.
|
|
//
|
|
// ⚠ IT COMPARES AGAINST Stack.CatalogImages, NEVER Stack.TemplateImages, AND v0.235.0 IS WHY.
|
|
// Since the freeze, a pinned app's LIVE docker-compose.yml is rendered from its own stored
|
|
// definition once the catalog moves past it — so the live file names the OLD version, installed
|
|
// would equal template, and this function would answer „Naprakész" on precisely the apps that are
|
|
// behind. It would invert the feature silently, with every test still green, because the two fields
|
|
// have the same type and shape. CatalogImages is read from the syncer's git clone instead.
|
|
//
|
|
// 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.CatalogImages) == 0 {
|
|
return updateUnknown // no readable catalog template — cannot tell, so say nothing
|
|
}
|
|
if len(s.AppConfig.InstalledImages) != len(s.CatalogImages) {
|
|
// 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.CatalogImages {
|
|
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()) }
|