5fdd2039fd
The previous commit landed only the new test/badge files: a 'git stash' used to compare REUSE.md ref-check output silently dropped the staged index, so every modification to an existing file was left behind and that commit does not build. This adds the metadata field, the predicates, the fail-closed deploy gate, the catalog filter, the funcmap entries, the template edits and the docs that those tests exercise.
406 lines
20 KiB
Go
406 lines
20 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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|