Files
deploy-felhom-compose/controller/internal/stacks/metadata.go
T
admin 8e61cd7ec4 feat: comprehensive INFO/WARN/ERROR logging across all controller modules
Add structured operational logging at INFO, WARN, and ERROR levels to
every controller module. Standardize custom prefixes ([GEO], [SCHED],
[SYNC]) to use [INFO/WARN/ERROR] [module] format. Fix misleveled logs
(WARN->ERROR for data loss scenarios, WARN->INFO for routine operations).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:58:27 +01:00

226 lines
8.7 KiB
Go

package stacks
import (
"log"
"os"
"path/filepath"
"strings"
"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"`
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"`
}
// 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"`
}
// 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
}
// 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"
}
// 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
}
}
return meta
}
// 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
}