Files
deploy-felhom-compose/controller/internal/stacks/metadata.go
T
admin 4c5d430b1a feat: controller-side HTTP/TCP health probes
Add network-level health probing from the controller to deployed apps.
The controller probes containers over the shared Docker network and
overrides stack state to "unhealthy" if the service isn't responding.

Three probe types: http (any response = alive), api (validates status
code and body content), tcp (port reachability). Configured per-app
via healthcheck: section in .felhom.yml. Runs every minute, per-app
interval defaults to 5 minutes.

This replaces Docker-level healthchecks for distroless images (e.g.
Vikunja) that lack shell utilities, and complements existing Docker
healthchecks for other apps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:11:21 +01:00

213 lines
8.1 KiB
Go

package stacks
import (
"fmt"
"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"`
}
// 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"`
}
// 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 {
fmt.Fprintf(os.Stderr, "[ERROR] Failed to parse .felhom.yml in %s: %v\n", 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
}