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:
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user