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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user