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()) }