feat: add custom health check commands per app
All checks were successful
Check / check (pull_request) Successful in 1m53s
All checks were successful
Check / check (pull_request) Successful in 1m53s
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user