feat: add custom health check commands per app
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:
user
2026-03-17 02:11:08 -07:00
parent fd110e69db
commit e2522f2017
12 changed files with 342 additions and 92 deletions

View File

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

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