catalog: the lifecycle implementation itself (fixes the previous commit)
The previous commit landed only the new test/badge files: a 'git stash' used to compare REUSE.md ref-check output silently dropped the staged index, so every modification to an existing file was left behind and that commit does not build. This adds the metadata field, the predicates, the fail-closed deploy gate, the catalog filter, the funcmap entries, the template edits and the docs that those tests exercise.
This commit is contained in:
@@ -410,6 +410,21 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
return
|
||||
}
|
||||
|
||||
// Lifecycle gate: an app withdrawn from the catalog (`lifecycle: hidden` / `abandoned`) is not
|
||||
// installable. FAIL-CLOSED and server-side on purpose — the catalog page already omits these, so
|
||||
// anything reaching here is a stale link, a bookmarked deploy form, or a direct POST, and a gate
|
||||
// that only hides the button is not a gate. Deliberately BEFORE every mutation.
|
||||
//
|
||||
// This does NOT touch an already-deployed instance: it is on the deploy path only, and the
|
||||
// manager refuses a redeploy of an existing stack through its own "already deployed" check.
|
||||
if st, ok := r.stackMgr.GetStack(name); ok && !st.Meta.CanInstall() {
|
||||
r.logger.Printf("[WARN] [api] Deploy refused for %s: lifecycle=%s (not offered for new installs)",
|
||||
name, st.Meta.EffectiveLifecycle())
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false,
|
||||
Error: "Ez az alkalmazás jelenleg nem telepíthető."})
|
||||
return
|
||||
}
|
||||
|
||||
// Prevention layer (storage-split): refuse a deploy when the Docker-data volume is at/under its
|
||||
// reserved buffer, so customer apps can't fill the volume the infra containers (controller,
|
||||
// traefik, cloudflared, filebrowser) depend on. Fail-OPEN on a measurement error — the buffer is
|
||||
|
||||
@@ -160,6 +160,16 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
meta := LoadMetadata(stackDir)
|
||||
|
||||
// --- Lifecycle gate (defence in depth) ---
|
||||
// The API handler refuses this first, with the customer-facing Hungarian message. This second
|
||||
// check exists because DeployStack is the manager-level choke point EVERY caller goes through,
|
||||
// and metadata is already loaded here — so a future caller that does not route through the API
|
||||
// cannot bypass the rule by simply not knowing about it. Deliberately before the first mutation.
|
||||
if !meta.CanInstall() {
|
||||
clearDeploying()
|
||||
return "", fmt.Errorf("stack %q is not installable (lifecycle: %s)", req.StackName, meta.EffectiveLifecycle())
|
||||
}
|
||||
|
||||
// --- Memory validation ---
|
||||
var deployWarning string
|
||||
reservedMB := m.cfg.System.ReservedMemoryMB
|
||||
|
||||
@@ -17,6 +17,16 @@ type Metadata struct {
|
||||
Category string `yaml:"category" json:"category"`
|
||||
Subdomain string `yaml:"subdomain" json:"subdomain"`
|
||||
Slug string `yaml:"slug" json:"slug"`
|
||||
// Lifecycle governs whether this app is OFFERED for new installs. It never affects an app that
|
||||
// is already deployed — a customer running a hidden or abandoned app keeps full function, which
|
||||
// is the whole point: removing a template from the catalog would orphan them instead.
|
||||
// ""/"available" — normal.
|
||||
// "hidden" — not offered for new installs. No explanation owed.
|
||||
// "abandoned" — not offered for new installs, AND every box already running it shows a
|
||||
// permanent notice that updates and security fixes will no longer arrive.
|
||||
// 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"`
|
||||
// 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"`
|
||||
@@ -193,6 +203,44 @@ type HealthCheckExpect struct {
|
||||
BodyContains string `yaml:"body_contains" json:"body_contains"` // string that must appear in response body
|
||||
}
|
||||
|
||||
// Lifecycle values. Absent/empty ≡ LifecycleAvailable.
|
||||
const (
|
||||
LifecycleAvailable = "available"
|
||||
LifecycleHidden = "hidden"
|
||||
LifecycleAbandoned = "abandoned"
|
||||
)
|
||||
|
||||
// EffectiveLifecycle normalises Metadata.Lifecycle. It is the SINGLE definition of "what state is
|
||||
// this app in" — every caller (catalog listing, deploy gate, badge, notice) must go through it, so
|
||||
// an unknown value can only ever be interpreted one way.
|
||||
//
|
||||
// Fail-OPEN is deliberate here, and it is the opposite of the deploy gate's posture on purpose:
|
||||
// an unrecognised value means the catalog is newer than this controller, and the safe reading of
|
||||
// "I do not know what this state is" is "leave the app alone" — the alternative would let a typo,
|
||||
// or a state added in a later release, silently pull a working app out of every customer's catalog.
|
||||
// The gate that actually protects against installing something is `CanInstall`, and it is fed by
|
||||
// this same function, so the two can never disagree.
|
||||
func (m *Metadata) EffectiveLifecycle() string {
|
||||
switch m.Lifecycle {
|
||||
case "", LifecycleAvailable:
|
||||
return LifecycleAvailable
|
||||
case LifecycleHidden:
|
||||
return LifecycleHidden
|
||||
case LifecycleAbandoned:
|
||||
return LifecycleAbandoned
|
||||
default:
|
||||
return LifecycleAvailable
|
||||
}
|
||||
}
|
||||
|
||||
// CanInstall reports whether this app may be offered/installed. The catalog listing and the deploy
|
||||
// endpoint MUST both use this — a template excluded from the list but accepted by a direct POST
|
||||
// would be a gate in name only.
|
||||
func (m *Metadata) CanInstall() bool { return m.EffectiveLifecycle() == LifecycleAvailable }
|
||||
|
||||
// IsAbandoned reports whether a DEPLOYED instance should carry the "no longer maintained" notice.
|
||||
func (m *Metadata) IsAbandoned() bool { return m.EffectiveLifecycle() == LifecycleAbandoned }
|
||||
|
||||
// LoadMetadata reads .felhom.yml from a stack directory.
|
||||
// Returns default metadata if the file doesn't exist.
|
||||
func LoadMetadata(stackDir string) Metadata {
|
||||
@@ -229,6 +277,14 @@ func LoadMetadata(stackDir string) Metadata {
|
||||
meta.Category = "tools"
|
||||
}
|
||||
|
||||
// Lifecycle: warn ONCE on an unrecognised value, then let EffectiveLifecycle degrade it to
|
||||
// available. Logged here rather than in EffectiveLifecycle because that is called on every
|
||||
// render — this is the one place per load, so a typo is visible without flooding the log.
|
||||
if meta.Lifecycle != "" && meta.EffectiveLifecycle() == LifecycleAvailable && meta.Lifecycle != LifecycleAvailable {
|
||||
log.Printf("[WARN] [stacks] %s: unknown lifecycle %q in .felhom.yml — treating as %q (known: %s, %s, %s)",
|
||||
dirName, meta.Lifecycle, LifecycleAvailable, LifecycleAvailable, LifecycleHidden, LifecycleAbandoned)
|
||||
}
|
||||
|
||||
// Default healthcheck fields
|
||||
if meta.HealthCheck != nil {
|
||||
if meta.HealthCheck.Interval == "" {
|
||||
|
||||
@@ -412,6 +412,14 @@ func (s *Server) templateFuncMap() template.FuncMap {
|
||||
}
|
||||
return s.cfg.AppPageURL(slug)
|
||||
},
|
||||
// lifecycleBadge returns the catalog-metadata pill for an app's lifecycle, or nil when
|
||||
// there is nothing to say. Pair it with the `meta_badge` partial, which no-ops on nil.
|
||||
// R-56's difficulty badge is meant to be a sibling entry returning the same *MetaBadge.
|
||||
"lifecycleBadge": lifecycleBadge,
|
||||
// canInstall reports whether a catalog template may be OFFERED for a new install. The
|
||||
// server-side deploy gate uses the same stacks.Metadata.CanInstall, so the button and the
|
||||
// endpoint can never disagree.
|
||||
"canInstall": func(m stacks.Metadata) bool { return m.CanInstall() },
|
||||
// infraMeta resolves a protected infra stack's curated Hungarian identity
|
||||
// (inframeta.go); nil for regular apps — templates branch on it.
|
||||
"infraMeta": infraMetaFor,
|
||||
|
||||
@@ -211,9 +211,30 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
s.executeTemplate(w, r, "dashboard", data)
|
||||
}
|
||||
|
||||
// visibleCatalogStacks drops templates that are no longer OFFERED for new installs (lifecycle
|
||||
// `hidden` or `abandoned`) AND are not deployed on this box.
|
||||
//
|
||||
// The `Deployed || Protected` half is the load-bearing part: a customer already running an app must
|
||||
// keep seeing and managing it, whatever the catalog now says about offering it to new customers.
|
||||
// Withdrawing an app must never take a working app away from someone — that is precisely the failure
|
||||
// the short-lived `retired/` directory move would have caused, and why lifecycle is a metadata field
|
||||
// rather than a deletion.
|
||||
//
|
||||
// Filtered here, in the handler, rather than in the template: the template already carries five
|
||||
// conditional badges per card, and a visibility rule buried among them is a rule nobody can test.
|
||||
func visibleCatalogStacks(in []stacks.Stack) []stacks.Stack {
|
||||
out := make([]stacks.Stack, 0, len(in))
|
||||
for _, st := range in {
|
||||
if st.Deployed || st.Protected || st.Meta.CanInstall() {
|
||||
out = append(out, st)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := s.baseData("stacks", "Alkalmazások")
|
||||
allStacks := s.stackMgr.GetStacks()
|
||||
allStacks := visibleCatalogStacks(s.stackMgr.GetStacks())
|
||||
data["Stacks"] = allStacks
|
||||
data["MissingStorage"] = s.missingStorageMap(allStacks)
|
||||
nw, ns := s.networkStorageWarnings(allStacks) // NAS unreachable (recoverable) / guest-side stub (defect)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
{{if .Stack.Deployed}}
|
||||
<span class="stack-state-badge state-{{stateColor .Stack.State}}">{{stateLabel .Stack.State}}</span>
|
||||
{{if .Stack.Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
||||
{{template "meta_badge" (lifecycleBadge .Meta)}}
|
||||
{{if .EffectiveSubdomain}}<a href="https://{{.EffectiveSubdomain}}.{{.Domain}}{{.Meta.OpenPath}}" target="_blank" class="btn btn-sm btn-outline">Megnyitás ↗</a>{{end}}
|
||||
<a href="/stacks/{{.Stack.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
||||
{{if .Stack.Orphaned}}
|
||||
@@ -19,7 +20,9 @@
|
||||
<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-outline">Beállítások</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-primary" onclick="return checkBeforeDeploy(event, '{{.Stack.Name}}')">Telepítés</a>
|
||||
{{/* Server-side the deploy endpoint refuses a non-installable template; hide the button
|
||||
so the page never offers an action that would be rejected. */}}
|
||||
{{if canInstall .Meta}}<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-primary" onclick="return checkBeforeDeploy(event, '{{.Stack.Name}}')">Telepítés</a>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,6 +52,12 @@
|
||||
{{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">Pi kompatibilis</span>{{else}}<span class="meta-badge meta-badge-warn">Csak x86</span>{{end}}
|
||||
{{if .Meta.Resources.HungarianUI}}<span class="meta-badge meta-badge-ok">Magyar felület</span>{{end}}
|
||||
</div>
|
||||
{{if .Meta.IsAbandoned}}
|
||||
<div class="alert alert-warning" style="margin-top:.75rem">
|
||||
Az alkalmazás fejlesztője felhagyott a fejlesztéssel. A telepített verzió továbbra is használható,
|
||||
de frissítések és biztonsági javítások már nem érkeznek hozzá.
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
{{end}}
|
||||
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
|
||||
{{if .Orphaned}}<span class="tag tag-warn">Elavult</span>{{end}}
|
||||
{{template "meta_badge" (lifecycleBadge .Meta)}}
|
||||
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="tag tag-warn" title="Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat egy másik tárhelyre."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hiányzó tárhely: {{$ms}}</span>{{end}}
|
||||
{{$ns := index $.NetworkStubs .Name}}{{if $ns}}<span class="tag tag-warn" title="Az alkalmazás környezetében a hálózati tárhely helyén üres helyi könyvtár van — az adatok nem a NAS-ra kerülnek. Jelezze az üzemeltetőnek."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja</span>{{end}}
|
||||
{{$nw := index $.NetworkWarnings .Name}}{{if $nw}}<span class="tag tag-warn" title="A hálózati tárhely (NAS) jelenleg nem érhető el. Az alkalmazás fut; az adatok elérése a NAS visszatértével helyreáll."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hálózati tárhely nem elérhető: {{$nw}}</span>{{end}}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
</div>
|
||||
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
|
||||
{{if .Orphaned}}<span class="tag tag-warn">Elavult</span>{{end}}
|
||||
{{template "meta_badge" (lifecycleBadge .Meta)}}
|
||||
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="tag tag-warn" title="Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hiányzó tárhely: {{$ms}}</span>{{end}}
|
||||
{{$ns := index $.NetworkStubs .Name}}{{if $ns}}<span class="tag tag-warn" title="Az alkalmazás környezetében a hálózati tárhely helyén üres helyi könyvtár van — az adatok nem a NAS-ra kerülnek. Jelezze az üzemeltetőnek."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja</span>{{end}}
|
||||
{{$nw := index $.NetworkWarnings .Name}}{{if $nw}}<span class="tag tag-warn" title="A hálózati tárhely (NAS) jelenleg nem érhető el. Az alkalmazás fut; az adatok elérése a NAS visszatértével helyreáll."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hálózati tárhely nem elérhető: {{$nw}}</span>{{end}}
|
||||
@@ -79,7 +80,9 @@
|
||||
<a href="/apps/{{.Meta.Slug}}" class="btn btn-outline">Részletek</a>
|
||||
{{end}}
|
||||
{{else if not .Deployed}}
|
||||
<a href="/stacks/{{.Name}}/deploy" class="btn btn-primary" onclick="return checkBeforeDeploy(event, '{{.Name}}')">Telepítés</a>
|
||||
{{/* The endpoint refuses a non-installable template server-side; hiding the button
|
||||
here keeps the two consistent rather than offering an action that will fail. */}}
|
||||
{{if canInstall .Meta}}<a href="/stacks/{{.Name}}/deploy" class="btn btn-primary" onclick="return checkBeforeDeploy(event, '{{.Name}}')">Telepítés</a>{{end}}
|
||||
<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>
|
||||
{{else}}
|
||||
{{if isOperational .State}}
|
||||
|
||||
Reference in New Issue
Block a user