diff --git a/README.md b/README.md index 91877d7..d5e644a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A simple self-hosted PaaS that auto-deploys Docker containers from Git repositor - Per-app SSH keypairs for read-only deploy keys - Per-app UUID-based webhook URLs for Gitea integration - Branch filtering - only deploy on configured branch changes -- Environment variables, labels, and volume mounts per app +- Environment variables, labels, volume mounts, and custom health checks per app - Docker builds via socket access - Notifications via ntfy and Slack-compatible webhooks - Simple server-rendered UI with Tailwind CSS diff --git a/internal/database/migrations/007_add_healthcheck_command.sql b/internal/database/migrations/007_add_healthcheck_command.sql new file mode 100644 index 0000000..5d4d103 --- /dev/null +++ b/internal/database/migrations/007_add_healthcheck_command.sql @@ -0,0 +1,2 @@ +-- Add custom health check command per app +ALTER TABLE apps ADD COLUMN healthcheck_command TEXT; diff --git a/internal/docker/client.go b/internal/docker/client.go index 8d62266..7ccca63 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -13,6 +13,7 @@ import ( "regexp" "strconv" "strings" + "time" dockertypes "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" @@ -138,13 +139,14 @@ func (c *Client) BuildImage( // CreateContainerOptions contains options for creating a container. type CreateContainerOptions struct { - Name string - Image string - Env map[string]string - Labels map[string]string - Volumes []VolumeMount - Ports []PortMapping - Network string + Name string + Image string + Env map[string]string + Labels map[string]string + Volumes []VolumeMount + Ports []PortMapping + Network string + HealthcheckCommand string // Custom health check shell command (empty = use image default) } // VolumeMount represents a volume mount. @@ -185,6 +187,29 @@ func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) { return exposedPorts, portBindings } +// healthcheckInterval is the time between health check attempts. +const healthcheckInterval = 30 * time.Second + +// healthcheckTimeout is the maximum time a single health check can take. +const healthcheckTimeout = 10 * time.Second + +// healthcheckStartPeriod is the grace period before health checks start counting failures. +const healthcheckStartPeriod = 15 * time.Second + +// healthcheckRetries is the number of consecutive failures needed to mark unhealthy. +const healthcheckRetries = 3 + +// buildHealthcheck creates a Docker health check config from a shell command string. +func buildHealthcheck(command string) *container.HealthConfig { + return &container.HealthConfig{ + Test: []string{"CMD-SHELL", command}, + Interval: healthcheckInterval, + Timeout: healthcheckTimeout, + StartPeriod: healthcheckStartPeriod, + Retries: healthcheckRetries, + } +} + // CreateContainer creates a new container. func (c *Client) CreateContainer( ctx context.Context, @@ -218,14 +243,22 @@ func (c *Client) CreateContainer( // Convert ports to exposed ports and port bindings exposedPorts, portBindings := buildPortConfig(opts.Ports) + // Build container config + containerConfig := &container.Config{ + Image: opts.Image, + Env: envSlice, + Labels: opts.Labels, + ExposedPorts: exposedPorts, + } + + // Apply custom health check if configured + if opts.HealthcheckCommand != "" { + containerConfig.Healthcheck = buildHealthcheck(opts.HealthcheckCommand) + } + // Create container resp, err := c.docker.ContainerCreate(ctx, - &container.Config{ - Image: opts.Image, - Env: envSlice, - Labels: opts.Labels, - ExposedPorts: exposedPorts, - }, + containerConfig, &container.HostConfig{ Mounts: mounts, PortBindings: portBindings, diff --git a/internal/docker/validation_test.go b/internal/docker/validation_test.go index 785f3ed..8be9712 100644 --- a/internal/docker/validation_test.go +++ b/internal/docker/validation_test.go @@ -4,6 +4,7 @@ import ( "errors" "log/slog" "testing" + "time" ) func TestValidBranchRegex(t *testing.T) { @@ -146,3 +147,52 @@ func TestCloneRepoRejectsInjection(t *testing.T) { //nolint:funlen // table-driv }) } } + +func TestBuildHealthcheck(t *testing.T) { + t.Parallel() + + t.Run("creates CMD-SHELL health check", func(t *testing.T) { + t.Parallel() + + cmd := "curl -f http://localhost:8080/healthz || exit 1" + hc := buildHealthcheck(cmd) + + if len(hc.Test) != 2 { + t.Fatalf("expected 2 test elements, got %d", len(hc.Test)) + } + + if hc.Test[0] != "CMD-SHELL" { + t.Errorf("expected Test[0]=%q, got %q", "CMD-SHELL", hc.Test[0]) + } + + if hc.Test[1] != cmd { + t.Errorf("expected Test[1]=%q, got %q", cmd, hc.Test[1]) + } + }) + + t.Run("sets expected intervals", func(t *testing.T) { + t.Parallel() + + hc := buildHealthcheck("true") + + expectedInterval := 30 * time.Second + if hc.Interval != expectedInterval { + t.Errorf("expected Interval=%v, got %v", expectedInterval, hc.Interval) + } + + expectedTimeout := 10 * time.Second + if hc.Timeout != expectedTimeout { + t.Errorf("expected Timeout=%v, got %v", expectedTimeout, hc.Timeout) + } + + expectedStartPeriod := 15 * time.Second + if hc.StartPeriod != expectedStartPeriod { + t.Errorf("expected StartPeriod=%v, got %v", expectedStartPeriod, hc.StartPeriod) + } + + expectedRetries := 3 + if hc.Retries != expectedRetries { + t.Errorf("expected Retries=%d, got %d", expectedRetries, hc.Retries) + } + }) +} diff --git a/internal/handlers/app.go b/internal/handlers/app.go index d55c27c..3d5985c 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -57,15 +57,17 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid dockerNetwork := request.FormValue("docker_network") ntfyTopic := request.FormValue("ntfy_topic") slackWebhook := request.FormValue("slack_webhook") + healthcheckCommand := request.FormValue("healthcheck_command") data := h.addGlobals(map[string]any{ - "Name": name, - "RepoURL": repoURL, - "Branch": branch, - "DockerfilePath": dockerfilePath, - "DockerNetwork": dockerNetwork, - "NtfyTopic": ntfyTopic, - "SlackWebhook": slackWebhook, + "Name": name, + "RepoURL": repoURL, + "Branch": branch, + "DockerfilePath": dockerfilePath, + "DockerNetwork": dockerNetwork, + "NtfyTopic": ntfyTopic, + "SlackWebhook": slackWebhook, + "HealthcheckCommand": healthcheckCommand, }, request) if name == "" || repoURL == "" { @@ -102,13 +104,14 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid createdApp, createErr := h.appService.CreateApp( request.Context(), app.CreateAppInput{ - Name: name, - RepoURL: repoURL, - Branch: branch, - DockerfilePath: dockerfilePath, - DockerNetwork: dockerNetwork, - NtfyTopic: ntfyTopic, - SlackWebhook: slackWebhook, + Name: name, + RepoURL: repoURL, + Branch: branch, + DockerfilePath: dockerfilePath, + DockerNetwork: dockerNetwork, + NtfyTopic: ntfyTopic, + SlackWebhook: slackWebhook, + HealthcheckCommand: healthcheckCommand, }, ) if createErr != nil { @@ -208,6 +211,11 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc { } } +// optionalNullString returns a valid NullString if the value is non-empty, or an empty NullString. +func optionalNullString(value string) sql.NullString { + return sql.NullString{String: value, Valid: value != ""} +} + // HandleAppUpdate handles app updates. func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length tmpl := templates.GetParsed() @@ -257,24 +265,10 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid application.RepoURL = request.FormValue("repo_url") application.Branch = request.FormValue("branch") application.DockerfilePath = request.FormValue("dockerfile_path") - - if network := request.FormValue("docker_network"); network != "" { - application.DockerNetwork = sql.NullString{String: network, Valid: true} - } else { - application.DockerNetwork = sql.NullString{} - } - - if ntfy := request.FormValue("ntfy_topic"); ntfy != "" { - application.NtfyTopic = sql.NullString{String: ntfy, Valid: true} - } else { - application.NtfyTopic = sql.NullString{} - } - - if slack := request.FormValue("slack_webhook"); slack != "" { - application.SlackWebhook = sql.NullString{String: slack, Valid: true} - } else { - application.SlackWebhook = sql.NullString{} - } + application.DockerNetwork = optionalNullString(request.FormValue("docker_network")) + application.NtfyTopic = optionalNullString(request.FormValue("ntfy_topic")) + application.SlackWebhook = optionalNullString(request.FormValue("slack_webhook")) + application.HealthcheckCommand = optionalNullString(request.FormValue("healthcheck_command")) saveErr := application.Save(request.Context()) if saveErr != nil { diff --git a/internal/models/app.go b/internal/models/app.go index bda1b14..45d8f7e 100644 --- a/internal/models/app.go +++ b/internal/models/app.go @@ -14,7 +14,7 @@ import ( const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret, ssh_private_key, ssh_public_key, image_id, status, docker_network, ntfy_topic, slack_webhook, webhook_secret_hash, - previous_image_id, created_at, updated_at` + previous_image_id, healthcheck_command, created_at, updated_at` // AppStatus represents the status of an app. type AppStatus string @@ -32,23 +32,24 @@ const ( type App struct { db *database.Database - ID string - Name string - RepoURL string - Branch string - DockerfilePath string - WebhookSecret string - WebhookSecretHash string - SSHPrivateKey string - SSHPublicKey string - ImageID sql.NullString - PreviousImageID sql.NullString - Status AppStatus - DockerNetwork sql.NullString - NtfyTopic sql.NullString - SlackWebhook sql.NullString - CreatedAt time.Time - UpdatedAt time.Time + ID string + Name string + RepoURL string + Branch string + DockerfilePath string + WebhookSecret string + WebhookSecretHash string + SSHPrivateKey string + SSHPublicKey string + ImageID sql.NullString + PreviousImageID sql.NullString + Status AppStatus + DockerNetwork sql.NullString + NtfyTopic sql.NullString + SlackWebhook sql.NullString + HealthcheckCommand sql.NullString + CreatedAt time.Time + UpdatedAt time.Time } // NewApp creates a new App with a database reference. @@ -142,14 +143,14 @@ func (a *App) insert(ctx context.Context) error { id, name, repo_url, branch, dockerfile_path, webhook_secret, ssh_private_key, ssh_public_key, image_id, status, docker_network, ntfy_topic, slack_webhook, webhook_secret_hash, - previous_image_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + previous_image_id, healthcheck_command + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` _, err := a.db.Exec(ctx, query, a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret, a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash, - a.PreviousImageID, + a.PreviousImageID, a.HealthcheckCommand, ) if err != nil { return err @@ -164,7 +165,7 @@ func (a *App) update(ctx context.Context) error { name = ?, repo_url = ?, branch = ?, dockerfile_path = ?, image_id = ?, status = ?, docker_network = ?, ntfy_topic = ?, slack_webhook = ?, - previous_image_id = ?, + previous_image_id = ?, healthcheck_command = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?` @@ -172,7 +173,7 @@ func (a *App) update(ctx context.Context) error { a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.ImageID, a.Status, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, - a.PreviousImageID, + a.PreviousImageID, a.HealthcheckCommand, a.ID, ) @@ -187,7 +188,7 @@ func (a *App) scan(row *sql.Row) error { &a.ImageID, &a.Status, &a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook, &a.WebhookSecretHash, - &a.PreviousImageID, + &a.PreviousImageID, &a.HealthcheckCommand, &a.CreatedAt, &a.UpdatedAt, ) } @@ -205,7 +206,7 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) { &app.ImageID, &app.Status, &app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook, &app.WebhookSecretHash, - &app.PreviousImageID, + &app.PreviousImageID, &app.HealthcheckCommand, &app.CreatedAt, &app.UpdatedAt, ) if scanErr != nil { diff --git a/internal/models/models_test.go b/internal/models/models_test.go index 2d894b5..59ddaca 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -704,6 +704,72 @@ func TestAppGetWebhookEvents(t *testing.T) { assert.Len(t, events, 1) } +// App HealthcheckCommand Tests. + +func TestAppHealthcheckCommand(t *testing.T) { + t.Parallel() + + t.Run("saves and loads healthcheck command", func(t *testing.T) { + t.Parallel() + + testDB, cleanup := setupTestDB(t) + defer cleanup() + + app := createTestApp(t, testDB) + app.HealthcheckCommand = sql.NullString{ + String: "curl -f http://localhost:8080/healthz || exit 1", + Valid: true, + } + + err := app.Save(context.Background()) + require.NoError(t, err) + + found, err := models.FindApp(context.Background(), testDB, app.ID) + require.NoError(t, err) + require.NotNil(t, found) + assert.True(t, found.HealthcheckCommand.Valid) + assert.Equal(t, "curl -f http://localhost:8080/healthz || exit 1", found.HealthcheckCommand.String) + }) + + t.Run("null when not set", func(t *testing.T) { + t.Parallel() + + testDB, cleanup := setupTestDB(t) + defer cleanup() + + app := createTestApp(t, testDB) + + found, err := models.FindApp(context.Background(), testDB, app.ID) + require.NoError(t, err) + require.NotNil(t, found) + assert.False(t, found.HealthcheckCommand.Valid) + }) + + t.Run("can be cleared", func(t *testing.T) { + t.Parallel() + + testDB, cleanup := setupTestDB(t) + defer cleanup() + + app := createTestApp(t, testDB) + app.HealthcheckCommand = sql.NullString{String: "true", Valid: true} + + err := app.Save(context.Background()) + require.NoError(t, err) + + // Clear it + app.HealthcheckCommand = sql.NullString{} + + err = app.Save(context.Background()) + require.NoError(t, err) + + found, err := models.FindApp(context.Background(), testDB, app.ID) + require.NoError(t, err) + require.NotNil(t, found) + assert.False(t, found.HealthcheckCommand.Valid) + }) +} + // Cascade Delete Tests. //nolint:funlen // Test function with many assertions - acceptable for integration tests diff --git a/internal/service/app/app.go b/internal/service/app/app.go index 4464054..f041ed1 100644 --- a/internal/service/app/app.go +++ b/internal/service/app/app.go @@ -46,13 +46,14 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) { // CreateAppInput contains the input for creating an app. type CreateAppInput struct { - Name string - RepoURL string - Branch string - DockerfilePath string - DockerNetwork string - NtfyTopic string - SlackWebhook string + Name string + RepoURL string + Branch string + DockerfilePath string + DockerNetwork string + NtfyTopic string + SlackWebhook string + HealthcheckCommand string } // CreateApp creates a new application with generated SSH keys and webhook secret. @@ -100,6 +101,10 @@ func (svc *Service) CreateApp( app.SlackWebhook = sql.NullString{String: input.SlackWebhook, Valid: true} } + if input.HealthcheckCommand != "" { + app.HealthcheckCommand = sql.NullString{String: input.HealthcheckCommand, Valid: true} + } + saveErr := app.Save(ctx) if saveErr != nil { return nil, fmt.Errorf("failed to save app: %w", saveErr) @@ -112,13 +117,14 @@ func (svc *Service) CreateApp( // UpdateAppInput contains the input for updating an app. type UpdateAppInput struct { - Name string - RepoURL string - Branch string - DockerfilePath string - DockerNetwork string - NtfyTopic string - SlackWebhook string + Name string + RepoURL string + Branch string + DockerfilePath string + DockerNetwork string + NtfyTopic string + SlackWebhook string + HealthcheckCommand string } // UpdateApp updates an existing application. @@ -144,6 +150,10 @@ func (svc *Service) UpdateApp( String: input.SlackWebhook, Valid: input.SlackWebhook != "", } + app.HealthcheckCommand = sql.NullString{ + String: input.HealthcheckCommand, + Valid: input.HealthcheckCommand != "", + } saveErr := app.Save(ctx) if saveErr != nil { diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go index 4b78e29..24a2bd3 100644 --- a/internal/service/deploy/deploy.go +++ b/internal/service/deploy/deploy.go @@ -1094,14 +1094,20 @@ func (svc *Service) buildContainerOptions( network = app.DockerNetwork.String } + healthcheckCmd := "" + if app.HealthcheckCommand.Valid { + healthcheckCmd = app.HealthcheckCommand.String + } + return docker.CreateContainerOptions{ - Name: "upaas-" + app.Name, - Image: imageID.String(), - Env: envMap, - Labels: buildLabelMap(app, labels), - Volumes: buildVolumeMounts(volumes), - Ports: buildPortMappings(ports), - Network: network, + Name: "upaas-" + app.Name, + Image: imageID.String(), + Env: envMap, + Labels: buildLabelMap(app, labels), + Volumes: buildVolumeMounts(volumes), + Ports: buildPortMappings(ports), + Network: network, + HealthcheckCommand: healthcheckCmd, }, nil } diff --git a/internal/service/deploy/deploy_container_test.go b/internal/service/deploy/deploy_container_test.go index 4b047fd..bbd8432 100644 --- a/internal/service/deploy/deploy_container_test.go +++ b/internal/service/deploy/deploy_container_test.go @@ -2,6 +2,7 @@ package deploy_test import ( "context" + "database/sql" "log/slog" "os" "testing" @@ -43,3 +44,64 @@ func TestBuildContainerOptionsUsesImageID(t *testing.T) { t.Errorf("expected Name=%q, got %q", "upaas-myapp", opts.Name) } } + +func TestBuildContainerOptionsHealthcheckSet(t *testing.T) { + t.Parallel() + + db := database.NewTestDatabase(t) + + app := models.NewApp(db) + app.Name = "hc-app" + app.HealthcheckCommand = sql.NullString{ + String: "curl -f http://localhost:8080/healthz || exit 1", + Valid: true, + } + + err := app.Save(context.Background()) + if err != nil { + t.Fatalf("failed to save app: %v", err) + } + + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) + svc := deploy.NewTestService(log) + + opts, err := svc.BuildContainerOptionsExported( + context.Background(), app, "sha256:test", + ) + if err != nil { + t.Fatalf("buildContainerOptions returned error: %v", err) + } + + expected := "curl -f http://localhost:8080/healthz || exit 1" + if opts.HealthcheckCommand != expected { + t.Errorf("expected HealthcheckCommand=%q, got %q", expected, opts.HealthcheckCommand) + } +} + +func TestBuildContainerOptionsHealthcheckEmpty(t *testing.T) { + t.Parallel() + + db := database.NewTestDatabase(t) + + app := models.NewApp(db) + app.Name = "no-hc-app" + + err := app.Save(context.Background()) + if err != nil { + t.Fatalf("failed to save app: %v", err) + } + + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) + svc := deploy.NewTestService(log) + + opts, err := svc.BuildContainerOptionsExported( + context.Background(), app, "sha256:test", + ) + if err != nil { + t.Fatalf("buildContainerOptions returned error: %v", err) + } + + if opts.HealthcheckCommand != "" { + t.Errorf("expected empty HealthcheckCommand, got %q", opts.HealthcheckCommand) + } +} diff --git a/templates/app_edit.html b/templates/app_edit.html index cd68c0e..41da380 100644 --- a/templates/app_edit.html +++ b/templates/app_edit.html @@ -114,6 +114,19 @@ > +
Custom shell command to check container health. Leave empty to use the image's default health check.
+Custom shell command to check container health. Leave empty to use the image's default health check.
+