4c5d430b1a
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>
213 lines
8.1 KiB
Go
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
|
|
}
|