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
+51
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gopkg.in/yaml.v3"
@@ -30,6 +31,15 @@ type Metadata struct {
// An UNKNOWN value degrades to available with one WARN (see LoadMetadata) — a typo in a catalog
// push must never brick a template.
Lifecycle string `yaml:"lifecycle,omitempty" json:"lifecycle,omitempty"`
// CatalogSince is the date (YYYY-MM-DD) on which THIS CATALOG last changed the app's pinned
// images. It is not a version and it is not an upstream release date — it answers only
// "how long has a newer pin been sitting in the catalog", which is the one thing a household
// can act on. The customer never sees a version string anywhere (operator ruling, 2026-09-02).
//
// OPTIONAL and TOLERANT in the Lifecycle style: absent, empty, malformed or dated in the FUTURE
// all degrade to "no age known" with one WARN, and the badge simply renders without an age. A
// catalog push must never be able to brick a template.
CatalogSince string `yaml:"catalog_since,omitempty" json:"catalog_since,omitempty"`
// OpenPath is appended to the app's public URL for the "Megnyitás" (open) link, for apps whose UI
// isn't at "/" (e.g. Gokapi → "/admin"). Empty = bare root. Must start with "/".
OpenPath string `yaml:"open_path,omitempty" json:"open_path,omitempty"`
@@ -256,6 +266,37 @@ func (m Metadata) CanInstall() bool { return m.EffectiveLifecycle() == Lifecycle
// IsAbandoned reports whether a DEPLOYED instance should carry the "no longer maintained" notice.
func (m Metadata) IsAbandoned() bool { return m.EffectiveLifecycle() == LifecycleAbandoned }
// catalogSinceLayout is the ONE accepted form. Deliberately a single strict layout rather than a
// list of tolerated ones: a date this code half-guesses at would print a confident "45 napja" from
// a value nobody checked.
const catalogSinceLayout = "2006-01-02"
// CatalogSinceAge returns how many WHOLE DAYS ago this app's pins last moved in the catalog, and
// whether that age is knowable at all. VALUE receiver, for the reason stated above CanInstall.
//
// FALSE — the age is unknown — for every degraded case: absent, empty, unparseable, and a date in
// the FUTURE. The future case is not pedantry: a box whose clock is behind the catalog's would
// otherwise render "-3 napja", which is worse than saying nothing. `now` is injected so the rule is
// a testable contract and not a property of the clock.
func (m Metadata) CatalogSinceAge(now time.Time) (int, bool) {
if strings.TrimSpace(m.CatalogSince) == "" {
return 0, false
}
since, err := time.Parse(catalogSinceLayout, strings.TrimSpace(m.CatalogSince))
if err != nil {
return 0, false
}
// Compare CALENDAR DAYS, not elapsed hours: "yesterday" must read as 1 napja whether it is now
// 00:30 or 23:30, and a duration division answers 0 for one of those.
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
sinceDay := time.Date(since.Year(), since.Month(), since.Day(), 0, 0, 0, 0, time.UTC)
days := int(today.Sub(sinceDay).Hours() / 24)
if days < 0 {
return 0, false
}
return days, true
}
// LoadMetadata reads .felhom.yml from a stack directory.
// Returns default metadata if the file doesn't exist.
func LoadMetadata(stackDir string) Metadata {
@@ -300,6 +341,16 @@ func LoadMetadata(stackDir string) Metadata {
dirName, meta.Lifecycle, LifecycleAvailable, LifecycleAvailable, LifecycleHidden, LifecycleAbandoned)
}
// catalog_since: warn ONCE per load on a value that is present but unusable, then let
// CatalogSinceAge degrade it to "no age known". Same placement and same reason as Lifecycle
// above — one line per load, not one per render.
if raw := strings.TrimSpace(meta.CatalogSince); raw != "" {
if _, ok := meta.CatalogSinceAge(time.Now().UTC()); !ok {
log.Printf("[WARN] [stacks] %s: unusable catalog_since %q in .felhom.yml (want YYYY-MM-DD, not in the future) — the update badge will show no age",
dirName, raw)
}
}
// Default healthcheck fields
if meta.HealthCheck != nil {
if meta.HealthCheck.Interval == "" {