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 `/userdata/media[/]` 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 = @. 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= and // FromDomainVar= separately instead of FromVar=@. 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 `