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:
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user