Compare commits

..

1 Commits

Author SHA1 Message Date
user
e2522f2017 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
2026-03-17 02:11:08 -07:00
17 changed files with 308 additions and 738 deletions

View File

@@ -8,8 +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 SSH keypairs for read-only deploy keys
- Per-app UUID-based webhook URLs for Gitea integration - Per-app UUID-based webhook URLs for Gitea integration
- Branch filtering - only deploy on configured branch changes - 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 - Docker builds via socket access
- Notifications via ntfy and Slack-compatible webhooks - Notifications via ntfy and Slack-compatible webhooks
- Simple server-rendered UI with Tailwind CSS - Simple server-rendered UI with Tailwind CSS

View File

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

View File

@@ -1,3 +0,0 @@
-- Add CPU and memory resource limits per app
ALTER TABLE apps ADD COLUMN cpu_limit REAL;
ALTER TABLE apps ADD COLUMN memory_limit INTEGER;

View File

@@ -13,6 +13,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time"
dockertypes "github.com/docker/docker/api/types" dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@@ -138,15 +139,14 @@ func (c *Client) BuildImage(
// CreateContainerOptions contains options for creating a container. // CreateContainerOptions contains options for creating a container.
type CreateContainerOptions struct { type CreateContainerOptions struct {
Name string Name string
Image string Image string
Env map[string]string Env map[string]string
Labels map[string]string Labels map[string]string
Volumes []VolumeMount Volumes []VolumeMount
Ports []PortMapping Ports []PortMapping
Network string Network string
CPULimit float64 // CPU cores (e.g. 0.5 = half a core, 2.0 = two cores). 0 means unlimited. HealthcheckCommand string // Custom health check shell command (empty = use image default)
MemoryLimit int64 // Memory in bytes. 0 means unlimited.
} }
// VolumeMount represents a volume mount. // VolumeMount represents a volume mount.
@@ -163,14 +163,6 @@ type PortMapping struct {
Protocol string // "tcp" or "udp" Protocol string // "tcp" or "udp"
} }
// nanoCPUsPerCPU is the number of NanoCPUs per CPU core.
const nanoCPUsPerCPU = 1e9
// cpuLimitToNanoCPUs converts a CPU limit (e.g. 0.5 cores) to Docker NanoCPUs.
func cpuLimitToNanoCPUs(cpuLimit float64) int64 {
return int64(cpuLimit * nanoCPUsPerCPU)
}
// buildPortConfig converts port mappings to Docker port configuration. // buildPortConfig converts port mappings to Docker port configuration.
func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) { func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
exposedPorts := make(nat.PortSet) exposedPorts := make(nat.PortSet)
@@ -195,46 +187,27 @@ func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
return exposedPorts, portBindings return exposedPorts, portBindings
} }
// buildEnvSlice converts an env map to a Docker-compatible env slice. // healthcheckInterval is the time between health check attempts.
func buildEnvSlice(env map[string]string) []string { const healthcheckInterval = 30 * time.Second
envSlice := make([]string, 0, len(env))
for key, val := range env { // healthcheckTimeout is the maximum time a single health check can take.
envSlice = append(envSlice, key+"="+val) 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,
} }
return envSlice
}
// buildMounts converts volume mounts to Docker mount configuration.
func buildMounts(volumes []VolumeMount) []mount.Mount {
mounts := make([]mount.Mount, 0, len(volumes))
for _, vol := range volumes {
mounts = append(mounts, mount.Mount{
Type: mount.TypeBind,
Source: vol.HostPath,
Target: vol.ContainerPath,
ReadOnly: vol.ReadOnly,
})
}
return mounts
}
// buildResources builds Docker resource constraints from container options.
func buildResources(opts CreateContainerOptions) container.Resources {
resources := container.Resources{}
if opts.CPULimit > 0 {
resources.NanoCPUs = cpuLimitToNanoCPUs(opts.CPULimit)
}
if opts.MemoryLimit > 0 {
resources.Memory = opts.MemoryLimit
}
return resources
} }
// CreateContainer creates a new container. // CreateContainer creates a new container.
@@ -248,20 +221,48 @@ func (c *Client) CreateContainer(
c.log.Info("creating container", "name", opts.Name, "image", opts.Image) c.log.Info("creating container", "name", opts.Name, "image", opts.Image)
// Convert env map to slice
envSlice := make([]string, 0, len(opts.Env))
for key, val := range opts.Env {
envSlice = append(envSlice, key+"="+val)
}
// Convert volumes to mounts
mounts := make([]mount.Mount, 0, len(opts.Volumes))
for _, vol := range opts.Volumes {
mounts = append(mounts, mount.Mount{
Type: mount.TypeBind,
Source: vol.HostPath,
Target: vol.ContainerPath,
ReadOnly: vol.ReadOnly,
})
}
// Convert ports to exposed ports and port bindings
exposedPorts, portBindings := buildPortConfig(opts.Ports) 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, resp, err := c.docker.ContainerCreate(ctx,
&container.Config{ containerConfig,
Image: opts.Image,
Env: buildEnvSlice(opts.Env),
Labels: opts.Labels,
ExposedPorts: exposedPorts,
},
&container.HostConfig{ &container.HostConfig{
Mounts: buildMounts(opts.Volumes), Mounts: mounts,
PortBindings: portBindings, PortBindings: portBindings,
NetworkMode: container.NetworkMode(opts.Network), NetworkMode: container.NetworkMode(opts.Network),
Resources: buildResources(opts),
RestartPolicy: container.RestartPolicy{ RestartPolicy: container.RestartPolicy{
Name: container.RestartPolicyUnlessStopped, Name: container.RestartPolicyUnlessStopped,
}, },

View File

@@ -1,31 +0,0 @@
package docker //nolint:testpackage // tests unexported cpuLimitToNanoCPUs
import (
"testing"
)
func TestCpuLimitToNanoCPUs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cpuLimit float64
expected int64
}{
{"one core", 1.0, 1_000_000_000},
{"half core", 0.5, 500_000_000},
{"two cores", 2.0, 2_000_000_000},
{"quarter core", 0.25, 250_000_000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := cpuLimitToNanoCPUs(tt.cpuLimit)
if got != tt.expected {
t.Errorf("cpuLimitToNanoCPUs(%v) = %d, want %d", tt.cpuLimit, got, tt.expected)
}
})
}
}

View File

@@ -4,6 +4,7 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"testing" "testing"
"time"
) )
func TestValidBranchRegex(t *testing.T) { 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") dockerNetwork := request.FormValue("docker_network")
ntfyTopic := request.FormValue("ntfy_topic") ntfyTopic := request.FormValue("ntfy_topic")
slackWebhook := request.FormValue("slack_webhook") slackWebhook := request.FormValue("slack_webhook")
healthcheckCommand := request.FormValue("healthcheck_command")
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"Name": name, "Name": name,
"RepoURL": repoURL, "RepoURL": repoURL,
"Branch": branch, "Branch": branch,
"DockerfilePath": dockerfilePath, "DockerfilePath": dockerfilePath,
"DockerNetwork": dockerNetwork, "DockerNetwork": dockerNetwork,
"NtfyTopic": ntfyTopic, "NtfyTopic": ntfyTopic,
"SlackWebhook": slackWebhook, "SlackWebhook": slackWebhook,
"HealthcheckCommand": healthcheckCommand,
}, request) }, request)
if name == "" || repoURL == "" { if name == "" || repoURL == "" {
@@ -102,13 +104,14 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
createdApp, createErr := h.appService.CreateApp( createdApp, createErr := h.appService.CreateApp(
request.Context(), request.Context(),
app.CreateAppInput{ app.CreateAppInput{
Name: name, Name: name,
RepoURL: repoURL, RepoURL: repoURL,
Branch: branch, Branch: branch,
DockerfilePath: dockerfilePath, DockerfilePath: dockerfilePath,
DockerNetwork: dockerNetwork, DockerNetwork: dockerNetwork,
NtfyTopic: ntfyTopic, NtfyTopic: ntfyTopic,
SlackWebhook: slackWebhook, SlackWebhook: slackWebhook,
HealthcheckCommand: healthcheckCommand,
}, },
) )
if createErr != nil { 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. // HandleAppUpdate handles app updates.
func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
@@ -260,17 +268,7 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
application.DockerNetwork = optionalNullString(request.FormValue("docker_network")) application.DockerNetwork = optionalNullString(request.FormValue("docker_network"))
application.NtfyTopic = optionalNullString(request.FormValue("ntfy_topic")) application.NtfyTopic = optionalNullString(request.FormValue("ntfy_topic"))
application.SlackWebhook = optionalNullString(request.FormValue("slack_webhook")) application.SlackWebhook = optionalNullString(request.FormValue("slack_webhook"))
application.HealthcheckCommand = optionalNullString(request.FormValue("healthcheck_command"))
limitsErr := applyResourceLimits(application, request)
if limitsErr != "" {
data := h.addGlobals(map[string]any{
"App": application,
"Error": limitsErr,
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
return
}
saveErr := application.Save(request.Context()) saveErr := application.Save(request.Context())
if saveErr != nil { if saveErr != nil {
@@ -1364,129 +1362,6 @@ func validateVolumePaths(hostPath, containerPath string) error {
return nil return nil
} }
// ErrInvalidMemoryFormat is returned when a memory limit string cannot be parsed.
var ErrInvalidMemoryFormat = errors.New(
"must be a number with optional unit suffix (e.g. 256m, 1g, 512000000)",
)
// ErrNegativeValue is returned when a resource limit is negative.
var ErrNegativeValue = errors.New("value must be positive")
// Memory unit byte multipliers.
const (
kilobyte = 1024
megabyte = 1024 * 1024
gigabyte = 1024 * 1024 * 1024
)
// optionalNullString converts a form value to a sql.NullString.
// Returns a valid NullString if non-empty, invalid (NULL) if empty.
func optionalNullString(s string) sql.NullString {
if s != "" {
return sql.NullString{String: s, Valid: true}
}
return sql.NullString{}
}
// applyResourceLimits parses CPU and memory limit form values and applies them to the app.
// Returns an error message string if validation fails, or empty string on success.
func applyResourceLimits(application *models.App, request *http.Request) string {
cpuLimit, cpuErr := parseOptionalFloat64(request.FormValue("cpu_limit"))
if cpuErr != nil {
return "Invalid CPU limit: must be a positive number (e.g. 0.5, 1, 2)"
}
application.CPULimit = cpuLimit
memoryLimit, memErr := parseOptionalMemoryBytes(request.FormValue("memory_limit"))
if memErr != nil {
return "Invalid memory limit: " + memErr.Error()
}
application.MemoryLimit = memoryLimit
return ""
}
// memoryUnitMultiplier returns the byte multiplier for a memory unit suffix.
// Returns 0 if the suffix is not recognized.
func memoryUnitMultiplier(suffix byte) int64 {
switch suffix {
case 'k':
return kilobyte
case 'm':
return megabyte
case 'g':
return gigabyte
default:
return 0
}
}
// parseOptionalFloat64 parses an optional float64 form field.
// Returns a valid NullFloat64 if the string is non-empty and parses to a positive number.
// Returns an empty NullFloat64 if the string is empty.
// Returns an error if the string is non-empty but invalid or non-positive.
func parseOptionalFloat64(s string) (sql.NullFloat64, error) {
s = strings.TrimSpace(s)
if s == "" {
return sql.NullFloat64{}, nil
}
val, err := strconv.ParseFloat(s, 64)
if err != nil {
return sql.NullFloat64{}, fmt.Errorf("invalid number: %w", err)
}
if val <= 0 {
return sql.NullFloat64{}, ErrNegativeValue
}
return sql.NullFloat64{Float64: val, Valid: true}, nil
}
// parseOptionalMemoryBytes parses an optional memory limit string into bytes.
// Accepts plain bytes (e.g. "536870912") or suffixed values (e.g. "512m", "1g", "256k").
// Returns a valid NullInt64 with bytes if non-empty, empty NullInt64 if blank.
func parseOptionalMemoryBytes(s string) (sql.NullInt64, error) {
s = strings.TrimSpace(s)
if s == "" {
return sql.NullInt64{}, nil
}
s = strings.ToLower(s)
// Check for unit suffix
multiplier := memoryUnitMultiplier(s[len(s)-1])
if multiplier > 0 {
numStr := s[:len(s)-1]
val, err := strconv.ParseFloat(numStr, 64)
if err != nil {
return sql.NullInt64{}, ErrInvalidMemoryFormat
}
if val <= 0 {
return sql.NullInt64{}, ErrNegativeValue
}
return sql.NullInt64{Int64: int64(val * float64(multiplier)), Valid: true}, nil
}
// Plain bytes
val, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return sql.NullInt64{}, ErrInvalidMemoryFormat
}
if val <= 0 {
return sql.NullInt64{}, ErrNegativeValue
}
return sql.NullInt64{Int64: val, Valid: true}, nil
}
// formatDeployKey formats an SSH public key with a descriptive comment. // formatDeployKey formats an SSH public key with a descriptive comment.
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp // Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string { func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {

View File

@@ -1,195 +0,0 @@
package handlers //nolint:testpackage // tests unexported parsing functions
import (
"database/sql"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseOptionalFloat64(t *testing.T) {
t.Parallel()
t.Run("empty string returns invalid", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalFloat64("")
require.NoError(t, err)
assert.False(t, result.Valid)
})
t.Run("whitespace only returns invalid", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalFloat64(" ")
require.NoError(t, err)
assert.False(t, result.Valid)
})
t.Run("valid float", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalFloat64("0.5")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.InDelta(t, 0.5, result.Float64, 0.001)
})
t.Run("valid integer", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalFloat64("2")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.InDelta(t, 2.0, result.Float64, 0.001)
})
t.Run("negative value rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalFloat64("-1")
require.Error(t, err)
})
t.Run("zero value rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalFloat64("0")
require.Error(t, err)
})
t.Run("non-numeric rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalFloat64("abc")
require.Error(t, err)
})
}
func TestParseOptionalMemoryBytes(t *testing.T) { //nolint:funlen // table-driven test
t.Parallel()
t.Run("empty string returns invalid", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("")
require.NoError(t, err)
assert.False(t, result.Valid)
})
t.Run("whitespace only returns invalid", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes(" ")
require.NoError(t, err)
assert.False(t, result.Valid)
})
t.Run("plain bytes", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("536870912")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(536870912), result.Int64)
})
t.Run("megabytes suffix", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("256m")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(256*1024*1024), result.Int64)
})
t.Run("megabytes suffix uppercase", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("256M")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(256*1024*1024), result.Int64)
})
t.Run("gigabytes suffix", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("1g")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(1024*1024*1024), result.Int64)
})
t.Run("kilobytes suffix", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("512k")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(512*1024), result.Int64)
})
t.Run("fractional gigabytes", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("1.5g")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(1.5*1024*1024*1024), result.Int64)
})
t.Run("negative value rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalMemoryBytes("-256m")
require.Error(t, err)
})
t.Run("zero value rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalMemoryBytes("0")
require.Error(t, err)
})
t.Run("invalid string rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalMemoryBytes("abc")
require.Error(t, err)
})
t.Run("negative plain bytes rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalMemoryBytes("-100")
require.Error(t, err)
})
}
func TestAppResourceLimitsRoundTrip(t *testing.T) {
t.Parallel()
// Test that parsing and formatting are consistent
tests := []struct {
input string
expected sql.NullInt64
format string
}{
{"256m", sql.NullInt64{Int64: 256 * 1024 * 1024, Valid: true}, "256m"},
{"1g", sql.NullInt64{Int64: 1024 * 1024 * 1024, Valid: true}, "1g"},
{"512k", sql.NullInt64{Int64: 512 * 1024, Valid: true}, "512k"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes(tt.input)
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -14,8 +14,7 @@ import (
const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret, const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status, ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash, docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
previous_image_id, cpu_limit, memory_limit, previous_image_id, healthcheck_command, created_at, updated_at`
created_at, updated_at`
// AppStatus represents the status of an app. // AppStatus represents the status of an app.
type AppStatus string type AppStatus string
@@ -33,25 +32,24 @@ const (
type App struct { type App struct {
db *database.Database db *database.Database
ID string ID string
Name string Name string
RepoURL string RepoURL string
Branch string Branch string
DockerfilePath string DockerfilePath string
WebhookSecret string WebhookSecret string
WebhookSecretHash string WebhookSecretHash string
SSHPrivateKey string SSHPrivateKey string
SSHPublicKey string SSHPublicKey string
ImageID sql.NullString ImageID sql.NullString
PreviousImageID sql.NullString PreviousImageID sql.NullString
Status AppStatus Status AppStatus
DockerNetwork sql.NullString DockerNetwork sql.NullString
NtfyTopic sql.NullString NtfyTopic sql.NullString
SlackWebhook sql.NullString SlackWebhook sql.NullString
CPULimit sql.NullFloat64 HealthcheckCommand sql.NullString
MemoryLimit sql.NullInt64 CreatedAt time.Time
CreatedAt time.Time UpdatedAt time.Time
UpdatedAt time.Time
} }
// NewApp creates a new App with a database reference. // NewApp creates a new App with a database reference.
@@ -145,14 +143,14 @@ func (a *App) insert(ctx context.Context) error {
id, name, repo_url, branch, dockerfile_path, webhook_secret, id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status, ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash, docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
previous_image_id, cpu_limit, memory_limit previous_image_id, healthcheck_command
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := a.db.Exec(ctx, query, _, err := a.db.Exec(ctx, query,
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret, a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status, a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash,
a.PreviousImageID, a.CPULimit, a.MemoryLimit, a.PreviousImageID, a.HealthcheckCommand,
) )
if err != nil { if err != nil {
return err return err
@@ -167,8 +165,7 @@ func (a *App) update(ctx context.Context) error {
name = ?, repo_url = ?, branch = ?, dockerfile_path = ?, name = ?, repo_url = ?, branch = ?, dockerfile_path = ?,
image_id = ?, status = ?, image_id = ?, status = ?,
docker_network = ?, ntfy_topic = ?, slack_webhook = ?, docker_network = ?, ntfy_topic = ?, slack_webhook = ?,
previous_image_id = ?, previous_image_id = ?, healthcheck_command = ?,
cpu_limit = ?, memory_limit = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ?` WHERE id = ?`
@@ -176,8 +173,7 @@ func (a *App) update(ctx context.Context) error {
a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.Name, a.RepoURL, a.Branch, a.DockerfilePath,
a.ImageID, a.Status, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
a.PreviousImageID, a.PreviousImageID, a.HealthcheckCommand,
a.CPULimit, a.MemoryLimit,
a.ID, a.ID,
) )
@@ -192,8 +188,7 @@ func (a *App) scan(row *sql.Row) error {
&a.ImageID, &a.Status, &a.ImageID, &a.Status,
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook, &a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
&a.WebhookSecretHash, &a.WebhookSecretHash,
&a.PreviousImageID, &a.PreviousImageID, &a.HealthcheckCommand,
&a.CPULimit, &a.MemoryLimit,
&a.CreatedAt, &a.UpdatedAt, &a.CreatedAt, &a.UpdatedAt,
) )
} }
@@ -211,8 +206,7 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
&app.ImageID, &app.Status, &app.ImageID, &app.Status,
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook, &app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
&app.WebhookSecretHash, &app.WebhookSecretHash,
&app.PreviousImageID, &app.PreviousImageID, &app.HealthcheckCommand,
&app.CPULimit, &app.MemoryLimit,
&app.CreatedAt, &app.UpdatedAt, &app.CreatedAt, &app.UpdatedAt,
) )
if scanErr != nil { if scanErr != nil {

View File

@@ -704,6 +704,72 @@ func TestAppGetWebhookEvents(t *testing.T) {
assert.Len(t, events, 1) 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. // Cascade Delete Tests.
//nolint:funlen // Test function with many assertions - acceptable for integration tests //nolint:funlen // Test function with many assertions - acceptable for integration tests
@@ -781,96 +847,6 @@ func TestCascadeDelete(t *testing.T) {
}) })
} }
// Resource Limits Tests.
func TestAppResourceLimits(t *testing.T) { //nolint:funlen // integration test with multiple subtests
t.Parallel()
t.Run("saves and loads CPU limit", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
app.CPULimit = sql.NullFloat64{Float64: 0.5, 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.CPULimit.Valid)
assert.InDelta(t, 0.5, found.CPULimit.Float64, 0.001)
})
t.Run("saves and loads memory limit", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
app.MemoryLimit = sql.NullInt64{Int64: 536870912, Valid: true} // 512m
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.MemoryLimit.Valid)
assert.Equal(t, int64(536870912), found.MemoryLimit.Int64)
})
t.Run("null limits by default", 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.CPULimit.Valid)
assert.False(t, found.MemoryLimit.Valid)
})
t.Run("clears limits when set to null", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
// Set limits
app.CPULimit = sql.NullFloat64{Float64: 1.0, Valid: true}
app.MemoryLimit = sql.NullInt64{Int64: 1073741824, Valid: true} // 1g
err := app.Save(context.Background())
require.NoError(t, err)
// Clear limits
app.CPULimit = sql.NullFloat64{}
app.MemoryLimit = sql.NullInt64{}
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.CPULimit.Valid)
assert.False(t, found.MemoryLimit.Valid)
})
}
// Helper function to create a test app. // Helper function to create a test app.
func createTestApp(t *testing.T, testDB *database.Database) *models.App { func createTestApp(t *testing.T, testDB *database.Database) *models.App {
t.Helper() t.Helper()

View File

@@ -46,13 +46,14 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
// CreateAppInput contains the input for creating an app. // CreateAppInput contains the input for creating an app.
type CreateAppInput struct { type CreateAppInput struct {
Name string Name string
RepoURL string RepoURL string
Branch string Branch string
DockerfilePath string DockerfilePath string
DockerNetwork string DockerNetwork string
NtfyTopic string NtfyTopic string
SlackWebhook string SlackWebhook string
HealthcheckCommand string
} }
// CreateApp creates a new application with generated SSH keys and webhook secret. // 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} 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) saveErr := app.Save(ctx)
if saveErr != nil { if saveErr != nil {
return nil, fmt.Errorf("failed to save app: %w", saveErr) 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. // UpdateAppInput contains the input for updating an app.
type UpdateAppInput struct { type UpdateAppInput struct {
Name string Name string
RepoURL string RepoURL string
Branch string Branch string
DockerfilePath string DockerfilePath string
DockerNetwork string DockerNetwork string
NtfyTopic string NtfyTopic string
SlackWebhook string SlackWebhook string
HealthcheckCommand string
} }
// UpdateApp updates an existing application. // UpdateApp updates an existing application.
@@ -144,6 +150,10 @@ func (svc *Service) UpdateApp(
String: input.SlackWebhook, String: input.SlackWebhook,
Valid: input.SlackWebhook != "", Valid: input.SlackWebhook != "",
} }
app.HealthcheckCommand = sql.NullString{
String: input.HealthcheckCommand,
Valid: input.HealthcheckCommand != "",
}
saveErr := app.Save(ctx) saveErr := app.Save(ctx)
if saveErr != nil { if saveErr != nil {

View File

@@ -1094,28 +1094,20 @@ func (svc *Service) buildContainerOptions(
network = app.DockerNetwork.String network = app.DockerNetwork.String
} }
var cpuLimit float64 healthcheckCmd := ""
if app.HealthcheckCommand.Valid {
if app.CPULimit.Valid { healthcheckCmd = app.HealthcheckCommand.String
cpuLimit = app.CPULimit.Float64
}
var memoryLimit int64
if app.MemoryLimit.Valid {
memoryLimit = app.MemoryLimit.Int64
} }
return docker.CreateContainerOptions{ return docker.CreateContainerOptions{
Name: "upaas-" + app.Name, Name: "upaas-" + app.Name,
Image: imageID.String(), Image: imageID.String(),
Env: envMap, Env: envMap,
Labels: buildLabelMap(app, labels), Labels: buildLabelMap(app, labels),
Volumes: buildVolumeMounts(volumes), Volumes: buildVolumeMounts(volumes),
Ports: buildPortMappings(ports), Ports: buildPortMappings(ports),
Network: network, Network: network,
CPULimit: cpuLimit, HealthcheckCommand: healthcheckCmd,
MemoryLimit: memoryLimit,
}, nil }, nil
} }

View File

@@ -45,13 +45,17 @@ func TestBuildContainerOptionsUsesImageID(t *testing.T) {
} }
} }
func TestBuildContainerOptionsNoResourceLimits(t *testing.T) { func TestBuildContainerOptionsHealthcheckSet(t *testing.T) {
t.Parallel() t.Parallel()
db := database.NewTestDatabase(t) db := database.NewTestDatabase(t)
app := models.NewApp(db) app := models.NewApp(db)
app.Name = "nolimits" app.Name = "hc-app"
app.HealthcheckCommand = sql.NullString{
String: "curl -f http://localhost:8080/healthz || exit 1",
Valid: true,
}
err := app.Save(context.Background()) err := app.Save(context.Background())
if err != nil { if err != nil {
@@ -62,29 +66,25 @@ func TestBuildContainerOptionsNoResourceLimits(t *testing.T) {
svc := deploy.NewTestService(log) svc := deploy.NewTestService(log)
opts, err := svc.BuildContainerOptionsExported( opts, err := svc.BuildContainerOptionsExported(
context.Background(), app, docker.ImageID("test:latest"), context.Background(), app, "sha256:test",
) )
if err != nil { if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err) t.Fatalf("buildContainerOptions returned error: %v", err)
} }
if opts.CPULimit != 0 { expected := "curl -f http://localhost:8080/healthz || exit 1"
t.Errorf("expected CPULimit=0, got %v", opts.CPULimit) if opts.HealthcheckCommand != expected {
} t.Errorf("expected HealthcheckCommand=%q, got %q", expected, opts.HealthcheckCommand)
if opts.MemoryLimit != 0 {
t.Errorf("expected MemoryLimit=0, got %v", opts.MemoryLimit)
} }
} }
func TestBuildContainerOptionsCPULimit(t *testing.T) { func TestBuildContainerOptionsHealthcheckEmpty(t *testing.T) {
t.Parallel() t.Parallel()
db := database.NewTestDatabase(t) db := database.NewTestDatabase(t)
app := models.NewApp(db) app := models.NewApp(db)
app.Name = "cpulimit" app.Name = "no-hc-app"
app.CPULimit = sql.NullFloat64{Float64: 0.5, Valid: true}
err := app.Save(context.Background()) err := app.Save(context.Background())
if err != nil { if err != nil {
@@ -95,42 +95,13 @@ func TestBuildContainerOptionsCPULimit(t *testing.T) {
svc := deploy.NewTestService(log) svc := deploy.NewTestService(log)
opts, err := svc.BuildContainerOptionsExported( opts, err := svc.BuildContainerOptionsExported(
context.Background(), app, docker.ImageID("test:latest"), context.Background(), app, "sha256:test",
) )
if err != nil { if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err) t.Fatalf("buildContainerOptions returned error: %v", err)
} }
if opts.CPULimit != 0.5 { if opts.HealthcheckCommand != "" {
t.Errorf("expected CPULimit=0.5, got %v", opts.CPULimit) t.Errorf("expected empty HealthcheckCommand, got %q", opts.HealthcheckCommand)
}
}
func TestBuildContainerOptionsMemoryLimit(t *testing.T) {
t.Parallel()
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = "memlimit"
app.MemoryLimit = sql.NullInt64{Int64: 536870912, Valid: true} // 512m
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, docker.ImageID("test:latest"),
)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
if opts.MemoryLimit != 536870912 {
t.Errorf("expected MemoryLimit=536870912, got %v", opts.MemoryLimit)
} }
} }

View File

@@ -114,36 +114,17 @@
> >
</div> </div>
<hr class="border-gray-200"> <div class="form-group">
<label for="healthcheck_command" class="label">Health Check Command</label>
<h3 class="text-lg font-medium text-gray-900">Resource Limits</h3> <input
type="text"
<div class="grid grid-cols-2 gap-4"> id="healthcheck_command"
<div class="form-group"> name="healthcheck_command"
<label for="cpu_limit" class="label">CPU Limit (cores)</label> value="{{if .App.HealthcheckCommand.Valid}}{{.App.HealthcheckCommand.String}}{{end}}"
<input class="input font-mono"
type="text" placeholder="curl -f http://localhost:8080/healthz || exit 1"
id="cpu_limit" >
name="cpu_limit" <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>
value="{{if .App.CPULimit.Valid}}{{.App.CPULimit.Float64}}{{end}}"
class="input"
placeholder="e.g. 0.5, 1, 2"
>
<p class="text-sm text-gray-500 mt-1">Number of CPU cores (e.g. 0.5 = half a core)</p>
</div>
<div class="form-group">
<label for="memory_limit" class="label">Memory Limit</label>
<input
type="text"
id="memory_limit"
name="memory_limit"
value="{{if .App.MemoryLimit.Valid}}{{formatMemoryBytes .App.MemoryLimit.Int64}}{{end}}"
class="input"
placeholder="e.g. 256m, 1g"
>
<p class="text-sm text-gray-500 mt-1">Memory with unit suffix (k, m, g) or plain bytes</p>
</div>
</div> </div>
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3 pt-4">

View File

@@ -117,6 +117,19 @@
> >
</div> </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"> <div class="flex justify-end gap-3 pt-4">
<a href="/" class="btn-secondary">Cancel</a> <a href="/" class="btn-secondary">Cancel</a>
<button type="submit" class="btn-primary">Create App</button> <button type="submit" class="btn-primary">Create App</button>

View File

@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"io" "io"
"strconv"
"sync" "sync"
) )
@@ -24,34 +23,6 @@ var (
templatesMutex sync.RWMutex templatesMutex sync.RWMutex
) )
// templateFuncs returns the custom template function map.
func templateFuncs() template.FuncMap {
return template.FuncMap{
"formatMemoryBytes": formatMemoryBytes,
}
}
// Memory unit constants.
const (
memGigabyte = 1024 * 1024 * 1024
memMegabyte = 1024 * 1024
memKilobyte = 1024
)
// formatMemoryBytes formats bytes into a human-readable string with unit suffix.
func formatMemoryBytes(bytes int64) string {
switch {
case bytes >= memGigabyte && bytes%memGigabyte == 0:
return strconv.FormatInt(bytes/memGigabyte, 10) + "g"
case bytes >= memMegabyte && bytes%memMegabyte == 0:
return strconv.FormatInt(bytes/memMegabyte, 10) + "m"
case bytes >= memKilobyte && bytes%memKilobyte == 0:
return strconv.FormatInt(bytes/memKilobyte, 10) + "k"
default:
return strconv.FormatInt(bytes, 10)
}
}
// initTemplates parses base template and creates cloned templates for each page. // initTemplates parses base template and creates cloned templates for each page.
func initTemplates() { func initTemplates() {
templatesMutex.Lock() templatesMutex.Lock()
@@ -61,10 +32,8 @@ func initTemplates() {
return return
} }
// Parse base template with shared components and custom functions // Parse base template with shared components
baseTemplate = template.Must( baseTemplate = template.Must(template.ParseFS(templatesRaw, "base.html"))
template.New("base.html").Funcs(templateFuncs()).ParseFS(templatesRaw, "base.html"),
)
// Pages that extend base // Pages that extend base
pages := []string{ pages := []string{

View File

@@ -1,34 +0,0 @@
package templates //nolint:testpackage // tests unexported formatMemoryBytes
import (
"testing"
)
func TestFormatMemoryBytes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
bytes int64
expected string
}{
{"gigabytes", 1024 * 1024 * 1024, "1g"},
{"two gigabytes", 2 * 1024 * 1024 * 1024, "2g"},
{"megabytes", 256 * 1024 * 1024, "256m"},
{"kilobytes", 512 * 1024, "512k"},
{"plain bytes", 12345, "12345"},
{"non-even megabytes", 256*1024*1024 + 1, "268435457"},
{"zero", 0, "0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := formatMemoryBytes(tt.bytes)
if got != tt.expected {
t.Errorf("formatMemoryBytes(%d) = %q, want %q", tt.bytes, got, tt.expected)
}
})
}
}