Files
felhom-controller/controller/internal/web/updatebadge.go
T
2026-09-25 11:19:27 +02:00

146 lines
6.8 KiB
Go

package web
import (
"fmt"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// updateState is the answer to "is this app running what the catalog currently pins?".
//
// THE THIRD VALUE 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.
//
// THE FOURTH VALUE, updateAhead, ARRIVED IN v0.260.0 (R-524). See stacks.CatalogOrder.
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
updateAhead // the box runs something NEWER than the catalog offers
)
// compareInstalledToTemplate answers the question WITHOUT touching the network.
//
// SINCE v0.260.0 IT IS A THIN WRAPPER OVER stacks.CatalogOrder, and that move is the point of R-524:
// the badge and the guarded update's downgrade refusal must reach the same verdict, and two
// implementations of one comparison are two verdicts waiting to disagree. Everything the old body
// said still holds and now lives in updateorder.go:
//
// - NO REGISTRY QUERY, deliberately: a customer's box must not depend on reaching eight upstream
// registries to render a page. The comparison is reference-to-reference.
// - ⚠ IT COMPARES AGAINST Stack.CatalogImages, NEVER Stack.TemplateImages, AND v0.235.0 IS WHY.
// Since the freeze, a pinned app's LIVE compose file is rendered from its own stored definition
// once the catalog moves past it, so installed would equal template and this function would
// answer „Naprakész" on precisely the apps that are behind — with every test still green,
// because the two fields have the same type and shape.
// - KNOWN LIMITATION: **10 of the catalog's 66 distinct pins FLOAT** (recounted 2026-09-21;
// the "23" this comment carried since v0.233.0 matched no definition the catalog supports
// today). For those the reference can be identical while the image behind it has moved
// upstream, and 6 of the 7 measurable ones HAVE moved since the catalog set them. Those apps
// read „Naprakész" when they may not be — R-446.
func compareInstalledToTemplate(s stacks.Stack) updateState {
switch stacks.CatalogOrder(s) {
case stacks.UpdateOrderCurrent:
return updateCurrent
case stacks.UpdateOrderBehind:
return updateBehind
case stacks.UpdateOrderAhead:
return updateAhead
default:
return updateUnknown
}
}
// 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.
//
// ⚠ THIS IS THE HUNGARIAN FORM AND IT KEEPS ITS LITERALS ON PURPOSE. The parity guarantee of the
// localisation arc is that templateFuncMap's Hungarian output is byte-identical to what it was
// before the bundle existed; the English form is rebuilt from the bundle in
// i18n_web.go localeFuncs, over the SAME compareInstalledToTemplate, so only the words differ and
// never the decision. A new branch here needs its twin there, and
// TestLocaleFuncsHungarianBundleMatchesFuncMap fails if hu.json and these literals disagree.
func updateBadgeAt(s stacks.Stack, now time.Time) *MetaBadge {
// R-625 (v0.265.0). A HELD app is stopped, and the only way back is a restore — a newer catalog
// version does not lift the hold (`09` §6.1), so „Frissítés elérhető" beside a button that answers
// 409 was two verdicts disagreeing. One verdict, read by the badge and by the button (stacks.html
// renders no Update button while HoldReason is set), exactly as R-524 did it for Ahead. True for
// both hold kinds: a failed restore holds the app stopped too, and the way back is also a restore.
// v0.269.0: an UNHEALTHY stop (decision 28) is not "restore needed" — its way back is Start.
if s.HoldReason != "" && s.HoldKind != "unhealthy_stop" {
return &MetaBadge{
Label: "Megállítva — visszaállítás szükséges",
Class: "tag-error",
Title: firstSentence(s.HoldReason),
}
}
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 updateAhead:
// R-524. THE PILL SAYS THE SAME WORD AS updateCurrent, and that is the ruling: an app that
// runs something newer than the catalog has nothing for the household to do, so it must not
// wear a warning. The note that says WHY goes in the title, which is the explanation slot
// this type exists for — a badge that only says a word is a riddle. No version number
// reaches the customer here either.
return &MetaBadge{
Label: "Naprakész",
Class: "tag-ok",
Title: "Ez az alkalmazás a katalógusnál újabb változatot futtat, ezért nincs teendőd.",
}
case updateBehind:
label := "Frissítés elérhető"
if days, ok := stacks.BehindSinceAge(s, now); ok { // R-677
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()) }
// firstSentence is the hold sentence's opening clause, for a badge title (R-625): up to and including
// the first full stop that ends a sentence; the whole text when it has none.
func firstSentence(s string) string {
if i := strings.Index(s, ". "); i >= 0 {
return s[:i+1]
}
return s
}