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>
This commit is contained in:
2026-02-25 11:11:21 +01:00
parent 077640d9bb
commit 4c5d430b1a
6 changed files with 425 additions and 13 deletions
+39
View File
@@ -20,6 +20,7 @@ type Metadata struct {
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.
@@ -77,6 +78,29 @@ type SelectOption struct {
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 {
@@ -113,6 +137,21 @@ func LoadMetadata(stackDir string) Metadata {
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" {