package stacks import ( "log" "os" "path/filepath" "strings" "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"` // 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"` 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"` } // 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"` } // 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"` 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"` } // 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 } // LoadMetadata reads .felhom.yml from a stack directory. // Returns default metadata if the file doesn't exist. func LoadMetadata(stackDir string) 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) } // 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 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) } // 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 { 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 }