e66d4669d6
gates / gates (push) Failing after 47s
An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.
settings Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
household + the install-time and optional settings. The default for an installed app,
RENDERED at the bare URL and never redirected to.
data the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
guide tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
link — and the WHOLE page for an app that is not installed
logs the log, and the only tab whose poller runs
The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.
The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.
A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.
New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.
The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).
template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
656 lines
35 KiB
Go
656 lines
35 KiB
Go
package stacks
|
|
|
|
import (
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Metadata holds app information parsed from .felhom.yml.
|
|
type Metadata struct {
|
|
DisplayName string `yaml:"display_name" json:"display_name"`
|
|
Description string `yaml:"description" json:"description"`
|
|
Category string `yaml:"category" json:"category"`
|
|
Subdomain string `yaml:"subdomain" json:"subdomain"`
|
|
// BrandColor is an optional launcher tile color (#rgb or #rrggbb). Invalid values fall back to
|
|
// the deterministic slug-derived color (see web.tileColor); no app sets it yet.
|
|
BrandColor string `yaml:"brand_color,omitempty" json:"brand_color,omitempty"`
|
|
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"`
|
|
// 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"`
|
|
Resources ResourceHints `yaml:"resources" json:"resources"`
|
|
DeployFields []DeployField `yaml:"deploy_fields" json:"deploy_fields"`
|
|
AppInfo AppInfo `yaml:"app_info" json:"app_info"`
|
|
OptionalConfig []OptionalConfigGroup `yaml:"optional_config" json:"optional_config"`
|
|
HealthCheck *HealthCheckConfig `yaml:"healthcheck,omitempty" json:"healthcheck,omitempty"`
|
|
// AfterLoad (`09` §3 decision 36, E1 2026-09-25) is ONE command the box runs once after it loads an
|
|
// app's kept data (a database from a backup under files kept on the drive) — for an app whose own
|
|
// index of its files must be rebuilt. Measured on nextcloud: a file written after the backup is on
|
|
// the drive and invisible until `occ files:scan --all`. Optional; absent = nothing runs.
|
|
AfterLoad *AfterLoadCommand `yaml:"after_load,omitempty" json:"after_load,omitempty"`
|
|
// AfterInstall (v0.279.0, `09` §3 decision 45) is ONE command the box runs once after a FRESH install —
|
|
// to replace a known default login with the generated one. See after_install.go.
|
|
AfterInstall *AfterInstallCommand `yaml:"after_install,omitempty" json:"after_install,omitempty"`
|
|
// SetupGate (v0.280.0, `09` §3 decision 46): a fresh install is CLOSED to everyone but the household until the
|
|
// app's first setup is done — for an app whose first visitor creates the admin. See setup_gate.go.
|
|
SetupGate bool `yaml:"setup_gate,omitempty" json:"setup_gate,omitempty"`
|
|
// MediaLibrary (v0.313.0, R-1023, `15-media-library.md` §2/§3): the app is a MEDIA app — the controller adds one
|
|
// bind per present, enrolled drive of `<drive>/userdata/media[/<folder>]` at the same path. See media_library.go.
|
|
MediaLibrary *MediaLibrarySpec `yaml:"media_library,omitempty" json:"media_library,omitempty"`
|
|
// SetupDoneProbe (v0.280.0) is the app's own read-only "an admin exists" status; absent = the household's button.
|
|
SetupDoneProbe *SetupDoneProbe `yaml:"setup_done_probe,omitempty" json:"setup_done_probe,omitempty"`
|
|
// SignupBlock (v0.281.0, `09` §3 decision 47): a traefik matcher for the app's own sign-up address, closed once the
|
|
// setup gate opens. See signup_block.go.
|
|
SignupBlock string `yaml:"signup_block,omitempty" json:"signup_block,omitempty"`
|
|
// AfterSetup (v0.282.0, decisions 47/49): the app's OWN sign-up switch, set when the gate opens. See after_setup.go.
|
|
AfterSetup *AfterSetupSpec `yaml:"after_setup,omitempty" json:"after_setup,omitempty"`
|
|
// FamilyGate (v0.287.0, `09` §3 decisions 63/64): a PERMANENT gate — only family members (and the household) reach
|
|
// the app; FamilyGateExcept are literal path prefixes a phone/e-reader app calls, left to the app's own login
|
|
// (anchored at a segment boundary). See family_gate.go.
|
|
FamilyGate bool `yaml:"family_gate,omitempty" json:"family_gate,omitempty"`
|
|
FamilyGateExcept []string `yaml:"family_gate_except,omitempty" json:"family_gate_except,omitempty"`
|
|
// AfterUncleanBoot (R-1007, `09` §3 decision 236): after an unclean host boot, the app rebuilds the files a power
|
|
// cut left empty, and its own lost-file check is read for the household's note. See after_unclean_boot.go.
|
|
AfterUncleanBoot *AfterUncleanBootSpec `yaml:"after_unclean_boot,omitempty" json:"after_unclean_boot,omitempty"`
|
|
// MinController (v0.287.0): the lowest box software that installs this template correctly; a lower one refuses.
|
|
MinController string `yaml:"min_controller,omitempty" json:"min_controller,omitempty"`
|
|
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
|
|
// InitialCreds: for apps that auto-generate a first-login credential into a file inside the
|
|
// container (e.g. Crafty's default-creds.txt). The controller reads + parses that file live and
|
|
// surfaces it on the app page, so the customer never has to dig through logs. Optional.
|
|
InitialCreds *InitialCredentials `yaml:"initial_credentials,omitempty" json:"initial_credentials,omitempty"`
|
|
// SMTPMapping declares how this app's compose env receives the managed app-email relay settings.
|
|
// Present only for apps that support outbound email; absent = the app has no email UI/injection.
|
|
SMTPMapping *SMTPMapping `yaml:"smtp_mapping,omitempty" json:"smtp_mapping,omitempty"`
|
|
// Backup is the referential-coupling classification block (Task 2). Present only for the 13
|
|
// bind-bearing apps; nil = legacy behavior (SQ5 two-level default). LoadMetadata REJECTS the whole
|
|
// block (sets this back to nil + logs one ERROR) on any validation defect, so a bad catalog push
|
|
// degrades to legacy loudly rather than partially classifying. Consumed by Task 3/4 — INERT today.
|
|
Backup *appbackup.BackupSpec `yaml:"backup,omitempty" json:"backup,omitempty"`
|
|
// DataPaths is the customer-facing folder ANNOTATION (R-75): role + Hungarian label over paths
|
|
// that must ALREADY exist as compose binds. It never declares a path. Validated in LoadMetadata
|
|
// with the Fork-3 asymmetry — a malformed PATH rejects the whole block, an unknown ROLE drops
|
|
// just that entry (see ValidateDataPaths).
|
|
DataPaths []DataPath `yaml:"data_paths,omitempty" json:"data_paths,omitempty"`
|
|
// I18n is the catalog's copy in every language OTHER than the Hungarian above (R-560). Keyed by
|
|
// language code; only "en" exists today. It is read through `For(lang)` and NEVER rendered
|
|
// directly — see metadata_i18n.go for the whole contract. `json:"-"`: this struct is serialised
|
|
// into the API answers and the page data, and the untranslated half of a bundle has no reader
|
|
// there; the merged view is what travels.
|
|
I18n map[string]MetadataOverlay `yaml:"i18n,omitempty" json:"-"`
|
|
}
|
|
|
|
// SMTPMapping renames the generic relay settings (host / port / security / from / from-name)
|
|
// to an app's specific env-var keys, plus any fixed Extra vars (e.g. accept-invalid-cert
|
|
// flags). When app-email is on (global + per-app) the controller injects these at
|
|
// deploy/redeploy: host = the in-controller shim, port = 2525, security = SecurityValue
|
|
// (the app's term for STARTTLS), from = <FromLocal>@<allowlisted-domain>. The values are
|
|
// NEVER persisted to app.yaml — they are derived from settings on every compose, so a
|
|
// toggle change applies on the next redeploy without rewriting secrets. (Spike §7.)
|
|
type SMTPMapping struct {
|
|
HostVar string `yaml:"host_var" json:"host_var"` // env key for the shim host (required)
|
|
PortVar string `yaml:"port_var" json:"port_var"` // env key for the port (required)
|
|
SecurityVar string `yaml:"security_var" json:"security_var"` // env key for the TLS mode (optional)
|
|
SecurityValue string `yaml:"security_value" json:"security_value"` // app term for STARTTLS (e.g. "starttls", "TLS")
|
|
FromVar string `yaml:"from_var" json:"from_var"` // env key for the From address (required)
|
|
FromNameVar string `yaml:"from_name_var" json:"from_name_var"` // env key for the From display name (optional)
|
|
FromLocal string `yaml:"from_local" json:"from_local"` // From local-part (defaults to the app slug)
|
|
Extra map[string]string `yaml:"extra" json:"extra"` // fixed extra env (accept-invalid-cert flags, etc.)
|
|
// TLSMode selects which shim listener the app is pointed at (which fixes the port):
|
|
// "" / "starttls" → :2525 (plaintext + STARTTLS advertised) — the default; existing apps unchanged.
|
|
// "plaintext" → :2526 (plaintext, STARTTLS NOT advertised) — for clients that opportunistically
|
|
// upgrade to STARTTLS and can't skip cert verification (cal.com, nextcloud).
|
|
// "implicit-tls" → :2465 (whole connection TLS).
|
|
TLSMode string `yaml:"tls_mode" json:"tls_mode"`
|
|
// FromDomainVar: for apps that SPLIT the From into local-part + domain env vars (nextcloud:
|
|
// MAIL_FROM_ADDRESS + MAIL_DOMAIN). When set, the controller injects FromVar=<local> and
|
|
// FromDomainVar=<allowlisted-domain> separately instead of FromVar=<local>@<domain>.
|
|
FromDomainVar string `yaml:"from_domain_var" json:"from_domain_var"`
|
|
}
|
|
|
|
// HasSMTPMapping reports whether this app declares a usable email mapping.
|
|
func (m *Metadata) HasSMTPMapping() bool {
|
|
return m.SMTPMapping != nil && m.SMTPMapping.HostVar != "" && m.SMTPMapping.FromVar != ""
|
|
}
|
|
|
|
// InitialCredentials tells the controller how to extract an app's auto-generated first-login
|
|
// credential from a file inside the running container. The file path is catalog-defined (trusted).
|
|
// Verification stays catalog-driven so any future self-seeding app can reuse the mechanism.
|
|
type InitialCredentials struct {
|
|
File string `yaml:"file" json:"file"` // path INSIDE the container
|
|
Format string `yaml:"format" json:"format"` // "json" | "regex" | "plain"
|
|
// Container overrides which container to read from; empty → the stack's main container.
|
|
Container string `yaml:"container,omitempty" json:"container,omitempty"`
|
|
// json format: which keys hold the username/password (password_key required; username optional).
|
|
UsernameKey string `yaml:"username_key,omitempty" json:"username_key,omitempty"`
|
|
PasswordKey string `yaml:"password_key,omitempty" json:"password_key,omitempty"`
|
|
// regex format: patterns whose first capture group is the value (password_pattern required).
|
|
UsernamePattern string `yaml:"username_pattern,omitempty" json:"username_pattern,omitempty"`
|
|
PasswordPattern string `yaml:"password_pattern,omitempty" json:"password_pattern,omitempty"`
|
|
// Note is shown alongside the credential (e.g. "initial password, change it after first login").
|
|
Note string `yaml:"note,omitempty" json:"note,omitempty"`
|
|
}
|
|
|
|
// AppInfo holds detailed app information for the info page.
|
|
type AppInfo struct {
|
|
Tagline string `yaml:"tagline" json:"tagline"`
|
|
UseCases []string `yaml:"use_cases" json:"use_cases"`
|
|
FirstSteps []string `yaml:"first_steps" json:"first_steps"`
|
|
Prerequisites []string `yaml:"prerequisites" json:"prerequisites"`
|
|
DefaultCreds string `yaml:"default_creds" json:"default_creds"`
|
|
DocsURL string `yaml:"docs_url" json:"docs_url"`
|
|
// AddPeople (v0.281.0, decision 47): how the household adds a family member once sign-up is closed.
|
|
AddPeople string `yaml:"add_people,omitempty" json:"add_people,omitempty"`
|
|
// Guide (v0.315.0): the app's own user guide, shown on the app page's „Utmutato" tab. Optional —
|
|
// an app with no guide block renders the tab's existing content and says a guide is still to come.
|
|
Guide AppGuide `yaml:"guide,omitempty" json:"guide,omitempty"`
|
|
}
|
|
|
|
// AppGuide is the catalog's user guide for one app: numbered sections and a FAQ, both plain text.
|
|
//
|
|
// CheckedVersion is NOT copy. It is the image tag the guide's author actually clicked through, and it
|
|
// is the render gate: a guide whose author never named a version is not shown at all (an unchecked
|
|
// guide that walks the household through a UI two releases old is worse than no guide). It is
|
|
// deliberately absent from the overlay below — a translator does not get to vouch for a version.
|
|
//
|
|
// Sections and FAQ entries are PLAIN TEXT and reach the page through html/template's escaping. No
|
|
// markdown, no raw HTML: catalog copy is data, and a step that contains `<script>` is shown as the
|
|
// characters the author typed. Pinned by TestGuide_RenderRules.
|
|
type AppGuide struct {
|
|
CheckedVersion string `yaml:"checked_version,omitempty" json:"checked_version,omitempty"`
|
|
Sections []GuideSection `yaml:"sections,omitempty" json:"sections,omitempty"`
|
|
FAQ []GuideFAQ `yaml:"faq,omitempty" json:"faq,omitempty"`
|
|
}
|
|
|
|
// GuideSection is one headed block of the guide: a title, an optional lead-in, and the steps.
|
|
type GuideSection struct {
|
|
Title string `yaml:"title" json:"title"`
|
|
Intro string `yaml:"intro,omitempty" json:"intro,omitempty"`
|
|
Steps []string `yaml:"steps,omitempty" json:"steps,omitempty"`
|
|
}
|
|
|
|
// GuideFAQ is one question and its answer.
|
|
type GuideFAQ struct {
|
|
Q string `yaml:"q" json:"q"`
|
|
A string `yaml:"a" json:"a"`
|
|
}
|
|
|
|
// Rendered reports whether this guide may be shown: its author named the version they checked it
|
|
// against, AND there is something to show. Both halves matter — `checked_version` alone renders an
|
|
// empty „Gyakori kerdesek" heading, and sections alone are a guide nobody vouched for.
|
|
//
|
|
// VALUE receiver: this type reaches html/template inside an interface{}, where a pointer-receiver
|
|
// method is a render-time 500 (the trap is on file in this repo twice).
|
|
func (g AppGuide) Rendered() bool {
|
|
return strings.TrimSpace(g.CheckedVersion) != "" && (len(g.Sections) > 0 || len(g.FAQ) > 0)
|
|
}
|
|
|
|
// Clone returns a copy sharing no slice with the receiver — the three in a guide are `Sections`,
|
|
// `FAQ` and each section's `Steps`.
|
|
//
|
|
// It exists for deepCopyStack (R-591): a Stack copy is „a snapshot the caller may mutate", and
|
|
// Metadata lives in the stack manager's cache shared by every request. TestDeepCopyStackMetaSharesNoReference
|
|
// fills every pointer, slice and map reachable from Metadata and fails on any the copy still shares —
|
|
// it caught these three the moment the guide was added, which is exactly what it is for.
|
|
func (g AppGuide) Clone() AppGuide {
|
|
out := g
|
|
if g.Sections != nil {
|
|
out.Sections = make([]GuideSection, len(g.Sections))
|
|
copy(out.Sections, g.Sections)
|
|
for i, se := range g.Sections {
|
|
if se.Steps != nil {
|
|
out.Sections[i].Steps = append([]string(nil), se.Steps...)
|
|
}
|
|
}
|
|
}
|
|
if g.FAQ != nil {
|
|
out.FAQ = make([]GuideFAQ, len(g.FAQ))
|
|
copy(out.FAQ, g.FAQ)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// OptionalConfigGroup defines a group of optional config fields (e.g., "Metadata providers").
|
|
type OptionalConfigGroup struct {
|
|
Group string `yaml:"group" json:"group"`
|
|
Description string `yaml:"description" json:"description"`
|
|
Fields []OptionalConfigField `yaml:"fields" json:"fields"`
|
|
}
|
|
|
|
// OptionalConfigField defines an individual optional config field.
|
|
type OptionalConfigField struct {
|
|
EnvVar string `yaml:"env_var" json:"env_var"`
|
|
Label string `yaml:"label" json:"label"`
|
|
Type string `yaml:"type" json:"type"`
|
|
HelpURL string `yaml:"help_url" json:"help_url"`
|
|
HelpText string `yaml:"help_text" json:"help_text"`
|
|
}
|
|
|
|
// ResourceHints describe what the app needs.
|
|
type ResourceHints struct {
|
|
MemRequest string `yaml:"mem_request" json:"mem_request"`
|
|
MemLimit string `yaml:"mem_limit" json:"mem_limit"`
|
|
PiCompatible bool `yaml:"pi_compatible" json:"pi_compatible"`
|
|
NeedsHDD bool `yaml:"needs_hdd" json:"needs_hdd"`
|
|
HungarianUI bool `yaml:"hungarian_ui" json:"hungarian_ui"`
|
|
}
|
|
|
|
// DeployField defines one configuration field shown during first deployment.
|
|
type DeployField struct {
|
|
EnvVar string `yaml:"env_var" json:"env_var"`
|
|
Label string `yaml:"label" json:"label"`
|
|
Type string `yaml:"type" json:"type"` // domain, subdomain, secret, password, path, text, select, boolean
|
|
Generate string `yaml:"generate" json:"generate"` // e.g., "password:24", "hex:32", "static:admin"
|
|
Default string `yaml:"default" json:"default"`
|
|
Required bool `yaml:"required" json:"required"`
|
|
Placeholder string `yaml:"placeholder" json:"placeholder"`
|
|
Description string `yaml:"description" json:"description"`
|
|
LockedAfterDeploy bool `yaml:"locked_after_deploy" json:"locked_after_deploy"`
|
|
// AfterStart (v0.313.0, R-1028/R-1045): this controller asks for the value AFTER the app started (Plex's claim code on
|
|
// the Plex page), so the wizard does not show the field and an empty value is allowed. An OLDER controller ignores the
|
|
// key and keeps the field — required — so a template can stay safe for both (a code made optional for everyone would
|
|
// let an old controller's „Kész" open an unclaimed Plex). Pinned by TestAfterStartField_*.
|
|
AfterStart bool `yaml:"after_start,omitempty" json:"after_start,omitempty"`
|
|
Options []SelectOption `yaml:"options" json:"options,omitempty"`
|
|
// DataKey marks a field as a DATA-ENCRYPTING key (e.g. AdventureLog's "Titkosítási kulcs"):
|
|
// the app encrypts stored data with it, so regenerating it would render restored data
|
|
// unreadable. It is a fail-closed annotation only — the recovery unit never stores secrets;
|
|
// at restore the controller refuses (rather than silently restoring garbage) if a data_key
|
|
// app's key cannot be recovered from the guest's app.yaml (live or via PBS). See Phase 2.
|
|
DataKey bool `yaml:"data_key,omitempty" json:"data_key,omitempty"`
|
|
}
|
|
|
|
// DataKeyEnvVars returns the env-var names of fields marked data_key:true.
|
|
func (m *Metadata) DataKeyEnvVars() []string {
|
|
var out []string
|
|
for _, f := range m.DeployFields {
|
|
if f.DataKey {
|
|
out = append(out, f.EnvVar)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// SelectOption is a choice for "select" type fields.
|
|
type SelectOption struct {
|
|
Value string `yaml:"value" json:"value"`
|
|
Label string `yaml:"label" json:"label"`
|
|
}
|
|
|
|
// IntegrationDef defines a single integration this app can provide to a target app.
|
|
type IntegrationDef struct {
|
|
Target string `yaml:"target" json:"target"` // target app slug: "filebrowser", "nextcloud"
|
|
Label string `yaml:"label" json:"label"` // UI label (Hungarian)
|
|
Description string `yaml:"description" json:"description"` // UI description
|
|
}
|
|
|
|
// HealthCheckConfig defines controller-side health probe configuration.
|
|
// When configured, the controller periodically probes the app's container
|
|
// and overrides the stack state to "unhealthy" if the service is not responding.
|
|
type HealthCheckConfig struct {
|
|
Interval string `yaml:"interval" json:"interval"` // e.g. "5m", "30s"; default "5m"
|
|
Checks []HealthCheckItem `yaml:"checks" json:"checks"`
|
|
// Container names the container to probe when the stack name does not resolve to one.
|
|
// It belongs on the BLOCK rather than on each check because every check in a block probes the
|
|
// same process — the port and path differ, the target does not.
|
|
//
|
|
// R-630: `paperless-ngx`'s containers are `paperless-webserver`/`-postgres`/`-redis`, so neither
|
|
// the exact-name rule nor the prefix rule finds anything and the stack was skipped SILENTLY — its
|
|
// probe had never run on any box, and `verifying` then waited out the full health timeout and
|
|
// STOPPED a working app. `immich` is the same shape with the opposite failure: no exact match and
|
|
// FOUR `immich-*` candidates, so the prefix fallback picked whichever came first.
|
|
// Precedent for the field: InitialCredentials.Container above.
|
|
Container string `yaml:"container,omitempty" json:"container,omitempty"`
|
|
}
|
|
|
|
// HealthCheckItem defines a single health check probe.
|
|
type HealthCheckItem struct {
|
|
Type string `yaml:"type" json:"type"` // "http", "api", "tcp"
|
|
Port int `yaml:"port" json:"port"`
|
|
Path string `yaml:"path" json:"path"` // for http/api; default "/"
|
|
Method string `yaml:"method" json:"method"` // for api; default "GET"
|
|
Expect *HealthCheckExpect `yaml:"expect,omitempty" json:"expect,omitempty"` // for api
|
|
}
|
|
|
|
// HealthCheckExpect defines expected response content for "api" type checks.
|
|
type HealthCheckExpect struct {
|
|
Status int `yaml:"status" json:"status"` // expected HTTP status code
|
|
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
|
|
}
|
|
}
|
|
|
|
// VALUE receivers on all three, deliberately: html/template cannot call a POINTER-receiver method
|
|
// on a non-addressable value, and `.Meta` reaches the templates as a stacks.Metadata VALUE inside a
|
|
// map[string]interface{}. With a pointer receiver `{{if .Meta.IsAbandoned}}` fails at RENDER time
|
|
// with "can't evaluate field IsAbandoned in type interface {}" — which is a 500 on the page, not a
|
|
// compile error, so nothing catches it until someone loads the page. That is exactly how it shipped
|
|
// once (v0.158.0, caught live the same hour). Keep these value receivers.
|
|
//
|
|
// 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 }
|
|
|
|
// 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 { return loadMetadata(stackDir, true) }
|
|
|
|
// LoadProbeMetadata reads a `.felhom.yml` for its health check and resources ONLY (R-670, v0.272.0): the
|
|
// backup and data_paths blocks are dropped instead of validated. For the copies that live without their
|
|
// compose file — the undo's `pre-update-meta/` and a step's `steps/<key>.felhom.yml` — the validation could
|
|
// only fail ("docker-compose.yml unreadable") and logged a false ERROR on every undo. Nothing read through
|
|
// this loader may consult Backup or DataPaths; they are always nil here.
|
|
// Pinned by TestR670_ProbeLoaderLogsNoFalseError.
|
|
func LoadProbeMetadata(stackDir string) Metadata {
|
|
m := loadMetadata(stackDir, false)
|
|
m.Backup, m.DataPaths = nil, nil
|
|
return m
|
|
}
|
|
|
|
func loadMetadata(stackDir string, validateBackup bool) Metadata {
|
|
meta := Metadata{}
|
|
|
|
path := filepath.Join(stackDir, ".felhom.yml")
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
// No metadata file — build defaults from directory name
|
|
dirName := filepath.Base(stackDir)
|
|
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
|
|
meta.Slug = dirName
|
|
meta.Category = "tools"
|
|
return meta
|
|
}
|
|
|
|
if err := yaml.Unmarshal(data, &meta); err != nil {
|
|
log.Printf("[ERROR] [stacks] Failed to parse .felhom.yml in %s: %v", stackDir, err)
|
|
dirName := filepath.Base(stackDir)
|
|
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
|
|
meta.Slug = dirName
|
|
return meta
|
|
}
|
|
|
|
// Fill in defaults for missing fields
|
|
dirName := filepath.Base(stackDir)
|
|
if meta.Slug == "" {
|
|
meta.Slug = dirName
|
|
}
|
|
if meta.DisplayName == "" {
|
|
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
|
|
}
|
|
if meta.Category == "" {
|
|
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)
|
|
}
|
|
|
|
// 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 == "" {
|
|
meta.HealthCheck.Interval = "5m"
|
|
}
|
|
for i := range meta.HealthCheck.Checks {
|
|
if meta.HealthCheck.Checks[i].Path == "" && (meta.HealthCheck.Checks[i].Type == "http" || meta.HealthCheck.Checks[i].Type == "api") {
|
|
meta.HealthCheck.Checks[i].Path = "/"
|
|
}
|
|
if meta.HealthCheck.Checks[i].Method == "" && meta.HealthCheck.Checks[i].Type == "api" {
|
|
meta.HealthCheck.Checks[i].Method = "GET"
|
|
}
|
|
}
|
|
}
|
|
|
|
// DOMAIN and SUBDOMAIN fields are always auto-filled/required — mark implicitly
|
|
for i := range meta.DeployFields {
|
|
if meta.DeployFields[i].Type == "domain" || meta.DeployFields[i].Type == "subdomain" {
|
|
meta.DeployFields[i].Required = true
|
|
meta.DeployFields[i].LockedAfterDeploy = true
|
|
}
|
|
// secret fields are always locked after deploy
|
|
if meta.DeployFields[i].Type == "secret" {
|
|
meta.DeployFields[i].LockedAfterDeploy = true
|
|
}
|
|
}
|
|
|
|
// Backup classification (Task 2): the SINGLE validation choke point. Catalog listing, the
|
|
// deployed-stack scan, and git-sync all flow through LoadMetadata, so a bad `backup:` block in a
|
|
// catalog push screams here within one sync cycle. On ANY defect (or an unreadable compose while a
|
|
// block exists) the WHOLE block is rejected — meta.Backup = nil, one ERROR — so the app degrades
|
|
// to legacy (today's behavior) rather than partially classifying. INERT: nothing consumes
|
|
// meta.Backup yet (Task 3/4).
|
|
if validateBackup && (meta.Backup != nil || len(meta.DataPaths) > 0) {
|
|
composePath := filepath.Join(stackDir, "docker-compose.yml")
|
|
binds := ParseComposeClassifiableBinds(composePath)
|
|
_, composeErr := os.Stat(composePath)
|
|
if meta.Backup != nil {
|
|
if composeErr != nil {
|
|
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: docker-compose.yml unreadable: %v", stackDir, composeErr)
|
|
meta.Backup = nil
|
|
} else if err := appbackup.ValidateBackupSpec(meta.Backup, binds); err != nil {
|
|
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: %v", stackDir, err)
|
|
meta.Backup = nil
|
|
}
|
|
}
|
|
// data_paths (R-75) rides the same choke point, with its own asymmetric rules: a malformed
|
|
// path drops the WHOLE block (nothing from it can be trusted), an unknown role drops only
|
|
// that entry (presentation, not data handling).
|
|
if len(meta.DataPaths) > 0 {
|
|
if composeErr != nil {
|
|
log.Printf("[ERROR] [stacks] .felhom.yml data_paths rejected in %s: docker-compose.yml unreadable: %v", stackDir, composeErr)
|
|
meta.DataPaths = nil
|
|
} else if kept, err := ValidateDataPaths(meta.DataPaths, binds, dirName, log.Default()); err != nil {
|
|
log.Printf("[ERROR] [stacks] .felhom.yml data_paths rejected in %s: %v", stackDir, err)
|
|
meta.DataPaths = nil
|
|
} else {
|
|
meta.DataPaths = kept
|
|
}
|
|
}
|
|
}
|
|
|
|
return meta
|
|
}
|
|
|
|
// ClassifiedBinds resolves the backup classification for a stack: it reads .felhom.yml (through the
|
|
// SAME LoadMetadata validation path, so a rejected block is already nil here) and its compose binds,
|
|
// then applies the two-level default via appbackup.ClassifyBinds. The bool reports whether the app
|
|
// carries a (valid) backup block at all. INERT — exists so Task 3 consumes a wired, end-to-end-tested
|
|
// seam instead of building one (the F-S3 lesson: wiring is where seams hide typos).
|
|
func (m *Manager) ClassifiedBinds(name string) ([]appbackup.ClassifiedBind, bool) {
|
|
// samba (R-7) is controller-generated infra with no .felhom.yml and absolute share binds — its
|
|
// classification comes from the shares registry instead (see samba_classify.go). Every other
|
|
// stack takes the unchanged catalog path below.
|
|
if name == SambaStackName {
|
|
return m.sambaClassifiedBinds()
|
|
}
|
|
stack, ok := m.GetStack(name)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
stackDir := filepath.Dir(stack.ComposePath)
|
|
meta := LoadMetadata(stackDir)
|
|
binds := ParseComposeClassifiableBinds(stack.ComposePath)
|
|
return appbackup.ClassifyBinds(meta.Backup, binds)
|
|
}
|
|
|
|
// HasDeployField reports whether the app declares a deploy field for the given env var.
|
|
//
|
|
// R-351: the caller that needs this is the restore prefill, and the question it is really asking is
|
|
// "does this app have somewhere to PUT a recorded value?". Measured 2026-08-21: only 13 of the 53
|
|
// catalog templates declare `env_var: HDD_PATH`; the other 40 have no storage field at all and their
|
|
// data lands on the system drive. For those, a recorded placement is a FACT TO STATE, never a value
|
|
// to offer — writing it into a field that does not exist would be a prefill nobody can see or change.
|
|
func (m *Metadata) HasDeployField(envVar string) bool {
|
|
for _, f := range m.DeployFields {
|
|
if f.EnvVar == envVar {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HasDeployFields returns true if the app has any user-facing deploy fields
|
|
// (i.e., fields beyond auto-filled domain and auto-generated secrets).
|
|
func (m *Metadata) HasDeployFields() bool {
|
|
for _, f := range m.DeployFields {
|
|
if f.Type != "domain" && f.Type != "secret" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// UserFacingFields returns only fields the user needs to interact with.
|
|
// Excludes auto-filled (domain) and fully hidden (secret) fields.
|
|
func (m *Metadata) UserFacingFields() []DeployField {
|
|
var fields []DeployField
|
|
for _, f := range m.DeployFields {
|
|
// R-1031 (v0.313.0): a media app sees EVERY drive, so the wizard does not ask it for one; its own config goes
|
|
// on the default drive (DeployStack fills HDD_PATH). Pinned by TestR1031_MediaAppIsNotAskedForADrive.
|
|
if m.MediaLibrary != nil && f.EnvVar == "HDD_PATH" {
|
|
continue
|
|
}
|
|
if f.AfterStart {
|
|
continue
|
|
}
|
|
if f.Type != "domain" && f.Type != "secret" {
|
|
fields = append(fields, f)
|
|
}
|
|
}
|
|
return fields
|
|
}
|
|
|
|
// AutoGeneratedFields returns fields that are generated without user input.
|
|
func (m *Metadata) AutoGeneratedFields() []DeployField {
|
|
var fields []DeployField
|
|
for _, f := range m.DeployFields {
|
|
if f.Type == "secret" || f.Type == "domain" {
|
|
fields = append(fields, f)
|
|
}
|
|
}
|
|
return fields
|
|
}
|
|
|
|
// HasAppInfo returns true if the metadata has any app info content.
|
|
func (m *Metadata) HasAppInfo() bool {
|
|
return m.AppInfo.Tagline != "" || len(m.AppInfo.UseCases) > 0 || len(m.AppInfo.FirstSteps) > 0
|
|
}
|
|
|
|
// HasOptionalConfig returns true if the metadata has any optional config groups.
|
|
func (m *Metadata) HasOptionalConfig() bool {
|
|
return len(m.OptionalConfig) > 0
|
|
}
|
|
|
|
// HasIntegrations returns true if the metadata defines any integrations.
|
|
func (m *Metadata) HasIntegrations() bool {
|
|
return len(m.Integrations) > 0
|
|
}
|
|
|
|
// AfterLoadCommand is `.felhom.yml`'s `after_load:` — run with `docker compose exec -T` in Service.
|
|
type AfterLoadCommand struct {
|
|
Service string `yaml:"service" json:"service"`
|
|
User string `yaml:"user,omitempty" json:"user,omitempty"`
|
|
Command []string `yaml:"command" json:"command"`
|
|
}
|