0fbd272ad2
Shipped in v0.158.0, caught live within the hour. /apps/<slug> returned 500
for EVERY app: html/template cannot call a pointer-receiver method on a
non-addressable value, and appDetailHandler passes Meta as a VALUE inside a
map[string]interface{}.
It compiled and every test passed because nothing rendered app_info - the
catalog tests used the funcmap route, which takes a value and works either
way. A template method call is only checked when the template runs.
Adds TestAppInfoRendersForEveryLifecycle with the handler's exact data shape.
413 lines
21 KiB
Go
413 lines
21 KiB
Go
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"`
|
|
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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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 {
|
|
composePath := filepath.Join(stackDir, "docker-compose.yml")
|
|
binds := ParseComposeClassifiableBinds(composePath)
|
|
if _, err := os.Stat(composePath); err != nil {
|
|
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: docker-compose.yml unreadable: %v", stackDir, err)
|
|
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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|