feat: add custom health check commands per app

Add configurable health check commands per app via a new
'healthcheck_command' field. When set, the command is passed
to Docker as a CMD-SHELL health check on the container.
When empty, the image's default health check is used.

Changes:
- Add migration 007 for healthcheck_command column on apps table
- Add HealthcheckCommand field to App model with full CRUD support
- Add buildHealthcheck() to docker client for CMD-SHELL config
- Pass health check command through CreateContainerOptions
- Add health check command input to app create/edit UI forms
- Extract optionalNullString helper to reduce handler complexity
- Update README features list

closes #81
This commit is contained in:
user
2026-03-17 02:11:08 -07:00
committed by clawbot
parent 67361419f5
commit 067cb86124
12 changed files with 352 additions and 79 deletions

View File

@@ -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
- CPU and memory resource limits per app
- Docker builds via socket access
- Notifications via ntfy and Slack-compatible webhooks

View File

@@ -0,0 +1,2 @@
-- Add custom health check command per app
ALTER TABLE apps ADD COLUMN healthcheck_command TEXT;

View File

@@ -13,6 +13,7 @@ import (
"regexp"
"strconv"
"strings"
"time"
dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
@@ -138,15 +139,16 @@ 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
CPULimit float64 // CPU cores (e.g. 0.5 = half a core, 2.0 = two cores). 0 means unlimited.
MemoryLimit int64 // Memory in bytes. 0 means unlimited.
Name string
Image string
Env map[string]string
Labels map[string]string
Volumes []VolumeMount
Ports []PortMapping
Network string
CPULimit float64 // CPU cores (e.g. 0.5 = half a core, 2.0 = two cores). 0 means unlimited.
MemoryLimit int64 // Memory in bytes. 0 means unlimited.
HealthcheckCommand string // Custom health check shell command (empty = use image default)
}
// VolumeMount represents a volume mount.
@@ -237,6 +239,29 @@ func buildResources(opts CreateContainerOptions) container.Resources {
return resources
}
// 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,
@@ -250,13 +275,22 @@ func (c *Client) CreateContainer(
exposedPorts, portBindings := buildPortConfig(opts.Ports)
// Build container config
containerConfig := &container.Config{
Image: opts.Image,
Env: buildEnvSlice(opts.Env),
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: buildEnvSlice(opts.Env),
Labels: opts.Labels,
ExposedPorts: exposedPorts,
},
containerConfig,
&container.HostConfig{
Mounts: buildMounts(opts.Volumes),
PortBindings: portBindings,

View File

@@ -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)
}
})
}

View File

@@ -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 {
@@ -260,6 +263,7 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
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"))
limitsErr := applyResourceLimits(application, request)
if limitsErr != "" {

View File

@@ -14,8 +14,8 @@ 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, cpu_limit, memory_limit,
created_at, updated_at`
previous_image_id, cpu_limit, memory_limit,
healthcheck_command, created_at, updated_at`
// AppStatus represents the status of an app.
type AppStatus string
@@ -33,25 +33,26 @@ 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
CPULimit sql.NullFloat64
MemoryLimit sql.NullInt64
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
CPULimit sql.NullFloat64
MemoryLimit sql.NullInt64
HealthcheckCommand sql.NullString
CreatedAt time.Time
UpdatedAt time.Time
}
// NewApp creates a new App with a database reference.
@@ -145,14 +146,16 @@ 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, cpu_limit, memory_limit
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
previous_image_id, cpu_limit, memory_limit,
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.CPULimit, a.MemoryLimit,
a.HealthcheckCommand,
)
if err != nil {
return err
@@ -167,8 +170,9 @@ 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 = ?,
cpu_limit = ?, memory_limit = ?,
healthcheck_command = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`
@@ -178,6 +182,7 @@ func (a *App) update(ctx context.Context) error {
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
a.PreviousImageID,
a.CPULimit, a.MemoryLimit,
a.HealthcheckCommand,
a.ID,
)
@@ -194,6 +199,7 @@ func (a *App) scan(row *sql.Row) error {
&a.WebhookSecretHash,
&a.PreviousImageID,
&a.CPULimit, &a.MemoryLimit,
&a.HealthcheckCommand,
&a.CreatedAt, &a.UpdatedAt,
)
}
@@ -213,6 +219,7 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
&app.WebhookSecretHash,
&app.PreviousImageID,
&app.CPULimit, &app.MemoryLimit,
&app.HealthcheckCommand,
&app.CreatedAt, &app.UpdatedAt,
)
if scanErr != nil {

View File

@@ -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

View File

@@ -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 {

View File

@@ -1094,6 +1094,29 @@ func (svc *Service) buildContainerOptions(
network = app.DockerNetwork.String
}
cpuLimit, memoryLimit := extractResourceLimits(app)
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,
CPULimit: cpuLimit,
MemoryLimit: memoryLimit,
HealthcheckCommand: healthcheckCmd,
}, nil
}
// extractResourceLimits returns CPU and memory limits from the app model.
func extractResourceLimits(app *models.App) (float64, int64) {
var cpuLimit float64
if app.CPULimit.Valid {
@@ -1106,17 +1129,7 @@ func (svc *Service) buildContainerOptions(
memoryLimit = app.MemoryLimit.Int64
}
return docker.CreateContainerOptions{
Name: "upaas-" + app.Name,
Image: imageID.String(),
Env: envMap,
Labels: buildLabelMap(app, labels),
Volumes: buildVolumeMounts(volumes),
Ports: buildPortMappings(ports),
Network: network,
CPULimit: cpuLimit,
MemoryLimit: memoryLimit,
}, nil
return cpuLimit, memoryLimit
}
func buildLabelMap(app *models.App, labels []*models.Label) map[string]string {

View File

@@ -134,3 +134,64 @@ func TestBuildContainerOptionsMemoryLimit(t *testing.T) {
t.Errorf("expected MemoryLimit=536870912, got %v", opts.MemoryLimit)
}
}
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)
}
}

View File

@@ -114,6 +114,19 @@
>
</div>
<div class="form-group">
<label for="healthcheck_command" class="label">Health Check Command</label>
<input
type="text"
id="healthcheck_command"
name="healthcheck_command"
value="{{if .App.HealthcheckCommand.Valid}}{{.App.HealthcheckCommand.String}}{{end}}"
class="input font-mono"
placeholder="curl -f http://localhost:8080/healthz || exit 1"
>
<p class="text-sm text-gray-500 mt-1">Custom shell command to check container health. Leave empty to use the image's default health check.</p>
</div>
<hr class="border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Resource Limits</h3>

View File

@@ -117,6 +117,19 @@
>
</div>
<div class="form-group">
<label for="healthcheck_command" class="label">Health Check Command</label>
<input
type="text"
id="healthcheck_command"
name="healthcheck_command"
value="{{.HealthcheckCommand}}"
class="input font-mono"
placeholder="curl -f http://localhost:8080/healthz || exit 1"
>
<p class="text-sm text-gray-500 mt-1">Custom shell command to check container health. Leave empty to use the image's default health check.</p>
</div>
<div class="flex justify-end gap-3 pt-4">
<a href="/" class="btn-secondary">Cancel</a>
<button type="submit" class="btn-primary">Create App</button>