Compare commits

..

2 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
fd110e69db feat: monolithic env var editing with bulk save (#158)
All checks were successful
Check / check (push) Successful in 6s
This PR fixes env var handling by consolidating individual add/edit/delete operations into a single monolithic bulk save.

## Changes

- **Template**: Restored original table-based UI with key/value rows, edit/delete buttons, and add form. Uses Alpine.js to manage the env var list client-side. On form submit, all env vars are collected into a hidden textarea and POSTed as a single bulk request.
- **Handler**: `HandleEnvVarSave` atomically replaces all env vars (DELETE all + INSERT full set).
- **Routes**: Single `POST /apps/{id}/env` endpoint replaces individual env var CRUD routes.
- **Models**: Added `DeleteEnvVarsByAppID` and `FindEnvVarsByAppID` for bulk operations.

closes #156
closes #163

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #158
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-11 12:06:56 +01:00
18 changed files with 823 additions and 260 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
- Docker builds via socket access
- Notifications via ntfy and Slack-compatible webhooks
- 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

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

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 {
@@ -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.
func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
tmpl := templates.GetParsed()
@@ -257,24 +265,10 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
application.RepoURL = request.FormValue("repo_url")
application.Branch = request.FormValue("branch")
application.DockerfilePath = request.FormValue("dockerfile_path")
if network := request.FormValue("docker_network"); network != "" {
application.DockerNetwork = sql.NullString{String: network, Valid: true}
} else {
application.DockerNetwork = sql.NullString{}
}
if ntfy := request.FormValue("ntfy_topic"); ntfy != "" {
application.NtfyTopic = sql.NullString{String: ntfy, Valid: true}
} else {
application.NtfyTopic = sql.NullString{}
}
if slack := request.FormValue("slack_webhook"); slack != "" {
application.SlackWebhook = sql.NullString{String: slack, Valid: true}
} else {
application.SlackWebhook = sql.NullString{}
}
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"))
saveErr := application.Save(request.Context())
if saveErr != nil {
@@ -903,50 +897,92 @@ func (h *Handlers) addKeyValueToApp(
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
}
// HandleEnvVarAdd handles adding an environment variable.
func (h *Handlers) HandleEnvVarAdd() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
h.addKeyValueToApp(
writer,
request,
func(ctx context.Context, application *models.App, key, value string) error {
envVar := models.NewEnvVar(h.db)
envVar.AppID = application.ID
envVar.Key = key
envVar.Value = value
return envVar.Save(ctx)
},
)
}
// envPairJSON represents a key-value pair in the JSON request body.
type envPairJSON struct {
Key string `json:"key"`
Value string `json:"value"`
}
// HandleEnvVarDelete handles deleting an environment variable.
func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc {
// envVarMaxBodyBytes is the maximum allowed request body size for env var saves (1 MB).
const envVarMaxBodyBytes = 1 << 20
// validateEnvPairs validates env var pairs.
// It rejects empty keys and duplicate keys (returns a non-empty error string).
func validateEnvPairs(pairs []envPairJSON) ([]models.EnvVarPair, string) {
seen := make(map[string]bool, len(pairs))
result := make([]models.EnvVarPair, 0, len(pairs))
for _, p := range pairs {
trimmedKey := strings.TrimSpace(p.Key)
if trimmedKey == "" {
return nil, "empty environment variable key is not allowed"
}
if seen[trimmedKey] {
return nil, "duplicate environment variable key: " + trimmedKey
}
seen[trimmedKey] = true
result = append(result, models.EnvVarPair{Key: trimmedKey, Value: p.Value})
}
return result, ""
}
// HandleEnvVarSave handles bulk saving of all environment variables.
// It reads a JSON array of {key, value} objects from the request body,
// deletes all existing env vars for the app, and inserts the full
// submitted set atomically within a database transaction.
// Duplicate keys are rejected with a 400 Bad Request error.
func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
envVarIDStr := chi.URLParam(request, "varID")
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
if parseErr != nil {
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
if findErr != nil || envVar == nil || envVar.AppID != appID {
http.NotFound(writer, request)
// Limit request body size to prevent abuse
request.Body = http.MaxBytesReader(writer, request.Body, envVarMaxBodyBytes)
var pairs []envPairJSON
decodeErr := json.NewDecoder(request.Body).Decode(&pairs)
if decodeErr != nil {
h.respondJSON(writer, request, map[string]string{
"error": "invalid request body",
}, http.StatusBadRequest)
return
}
deleteErr := envVar.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete env var", "error", deleteErr)
modelPairs, validationErr := validateEnvPairs(pairs)
if validationErr != "" {
h.respondJSON(writer, request, map[string]string{
"error": validationErr,
}, http.StatusBadRequest)
return
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
replaceErr := models.ReplaceEnvVarsByAppID(
request.Context(), h.db, application.ID, modelPairs,
)
if replaceErr != nil {
h.log.Error("failed to replace env vars", "error", replaceErr)
h.respondJSON(writer, request, map[string]string{
"error": "failed to save environment variables",
}, http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, map[string]bool{"ok": true}, http.StatusOK)
}
}
@@ -1205,59 +1241,6 @@ func ValidateVolumePath(p string) error {
return nil
}
// HandleEnvVarEdit handles editing an existing environment variable.
func (h *Handlers) HandleEnvVarEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
envVarIDStr := chi.URLParam(request, "varID")
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
if findErr != nil || envVar == nil || envVar.AppID != appID {
http.NotFound(writer, request)
return
}
formErr := request.ParseForm()
if formErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
key := request.FormValue("key")
value := request.FormValue("value")
if key == "" || value == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
envVar.Key = key
envVar.Value = value
saveErr := envVar.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update env var", "error", saveErr)
}
http.Redirect(
writer,
request,
"/apps/"+appID+"?success=env-updated",
http.StatusSeeOther,
)
}
}
// HandleLabelEdit handles editing an existing label.
func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {

View File

@@ -560,45 +560,242 @@ func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) {
cfg.verifyFn(t, testCtx, resourceID)
}
// TestDeleteEnvVarOwnershipVerification tests that deleting an env var
// via another app's URL path returns 404 (IDOR prevention).
func TestDeleteEnvVarOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern
// TestHandleEnvVarSaveBulk tests that HandleEnvVarSave replaces all env vars
// for an app with the submitted set (monolithic delete-all + insert-all).
func TestHandleEnvVarSaveBulk(t *testing.T) {
t.Parallel()
testOwnershipVerification(t, ownedResourceTestConfig{
appPrefix1: "envvar-owner-app",
appPrefix2: "envvar-other-app",
createFn: func(t *testing.T, tc *testContext, ownerApp *models.App) int64 {
t.Helper()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-bulk-app")
envVar := models.NewEnvVar(tc.database)
envVar.AppID = ownerApp.ID
envVar.Key = "SECRET"
envVar.Value = "hunter2"
require.NoError(t, envVar.Save(context.Background()))
// Create some pre-existing env vars
for _, kv := range [][2]string{{"OLD_KEY", "old_value"}, {"REMOVE_ME", "gone"}} {
ev := models.NewEnvVar(testCtx.database)
ev.AppID = createdApp.ID
ev.Key = kv[0]
ev.Value = kv[1]
require.NoError(t, ev.Save(context.Background()))
}
return envVar.ID
},
deletePath: func(appID string, resourceID int64) string {
return "/apps/" + appID + "/env/" + strconv.FormatInt(resourceID, 10) + "/delete"
},
chiParams: func(appID string, resourceID int64) map[string]string {
return map[string]string{"id": appID, "varID": strconv.FormatInt(resourceID, 10)}
},
handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleEnvVarDelete() },
verifyFn: func(t *testing.T, tc *testContext, resourceID int64) {
t.Helper()
// Submit a new set as a JSON array of key/value objects
body := `[{"key":"NEW_KEY","value":"new_value"},{"key":"ANOTHER","value":"42"}]`
found, findErr := models.FindEnvVar(context.Background(), tc.database, resourceID)
require.NoError(t, findErr)
assert.NotNil(t, found, "env var should still exist after IDOR attempt")
},
})
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
// Verify old env vars are gone and new ones exist
envVars, err := models.FindEnvVarsByAppID(
context.Background(), testCtx.database, createdApp.ID,
)
require.NoError(t, err)
assert.Len(t, envVars, 2)
keys := make(map[string]string)
for _, ev := range envVars {
keys[ev.Key] = ev.Value
}
assert.Equal(t, "new_value", keys["NEW_KEY"])
assert.Equal(t, "42", keys["ANOTHER"])
assert.Empty(t, keys["OLD_KEY"], "old env vars should be deleted")
assert.Empty(t, keys["REMOVE_ME"], "old env vars should be deleted")
}
// TestHandleEnvVarSaveAppNotFound tests that HandleEnvVarSave returns 404
// for a non-existent app.
func TestHandleEnvVarSaveAppNotFound(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
body := `[{"key":"KEY","value":"value"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/nonexistent-id/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusNotFound, recorder.Code)
}
// TestHandleEnvVarSaveEmptyKeyRejected verifies that submitting a JSON
// array containing an entry with an empty key returns 400.
func TestHandleEnvVarSaveEmptyKeyRejected(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-emptykey-app")
body := `[{"key":"VALID_KEY","value":"ok"},{"key":"","value":"bad"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
}
// TestHandleEnvVarSaveDuplicateKeyRejected verifies that when the client
// sends duplicate keys, the server rejects them with 400 Bad Request.
func TestHandleEnvVarSaveDuplicateKeyRejected(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-dedup-app")
// Send two entries with the same key — should be rejected
body := `[{"key":"FOO","value":"first"},{"key":"BAR","value":"bar"},{"key":"FOO","value":"second"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.Contains(t, recorder.Body.String(), "duplicate environment variable key: FOO")
}
// TestHandleEnvVarSaveCrossAppIsolation verifies that posting env vars
// to appA's endpoint does not affect appB's env vars (IDOR prevention).
func TestHandleEnvVarSaveCrossAppIsolation(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
appA := createTestApp(t, testCtx, "envvar-iso-appA")
appB := createTestApp(t, testCtx, "envvar-iso-appB")
// Give appB some env vars
for _, kv := range [][2]string{{"B_KEY1", "b_val1"}, {"B_KEY2", "b_val2"}} {
ev := models.NewEnvVar(testCtx.database)
ev.AppID = appB.ID
ev.Key = kv[0]
ev.Value = kv[1]
require.NoError(t, ev.Save(context.Background()))
}
// POST new env vars to appA's endpoint
body := `[{"key":"A_KEY","value":"a_val"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+appA.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
// Verify appA has exactly what we sent
appAVars, err := models.FindEnvVarsByAppID(
context.Background(), testCtx.database, appA.ID,
)
require.NoError(t, err)
assert.Len(t, appAVars, 1)
assert.Equal(t, "A_KEY", appAVars[0].Key)
// Verify appB's env vars are completely untouched
appBVars, err := models.FindEnvVarsByAppID(
context.Background(), testCtx.database, appB.ID,
)
require.NoError(t, err)
assert.Len(t, appBVars, 2, "appB env vars must not be affected")
bKeys := make(map[string]string)
for _, ev := range appBVars {
bKeys[ev.Key] = ev.Value
}
assert.Equal(t, "b_val1", bKeys["B_KEY1"])
assert.Equal(t, "b_val2", bKeys["B_KEY2"])
}
// TestHandleEnvVarSaveBodySizeLimit verifies that a request body
// exceeding the 1 MB limit is rejected.
func TestHandleEnvVarSaveBodySizeLimit(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-sizelimit-app")
// Build a JSON body that exceeds 1 MB
// Each entry is ~30 bytes; 40000 entries ≈ 1.2 MB
var sb strings.Builder
sb.WriteString("[")
for i := range 40000 {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(`{"key":"K` + strconv.Itoa(i) + `","value":"val"}`)
}
sb.WriteString("]")
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(sb.String()),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusBadRequest, recorder.Code,
"oversized body should be rejected with 400")
}
// TestDeleteLabelOwnershipVerification tests that deleting a label
// via another app's URL path returns 404 (IDOR prevention).
func TestDeleteLabelOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern
func TestDeleteLabelOwnershipVerification(t *testing.T) {
t.Parallel()
testOwnershipVerification(t, ownedResourceTestConfig{
@@ -714,41 +911,43 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
assert.NotNil(t, found, "port should still exist after IDOR attempt")
}
// TestHandleEnvVarDeleteUsesCorrectRouteParam verifies that HandleEnvVarDelete
// reads the "varID" chi URL parameter (matching the route definition {varID}),
// not a mismatched name like "envID".
func TestHandleEnvVarDeleteUsesCorrectRouteParam(t *testing.T) {
// TestHandleEnvVarSaveEmptyClears verifies that submitting an empty JSON
// array deletes all existing env vars for the app.
func TestHandleEnvVarSaveEmptyClears(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-clear-app")
createdApp := createTestApp(t, testCtx, "envdelete-param-app")
// Create a pre-existing env var
ev := models.NewEnvVar(testCtx.database)
ev.AppID = createdApp.ID
ev.Key = "DELETE_ME"
ev.Value = "gone"
require.NoError(t, ev.Save(context.Background()))
envVar := models.NewEnvVar(testCtx.database)
envVar.AppID = createdApp.ID
envVar.Key = "DELETE_ME"
envVar.Value = "gone"
require.NoError(t, envVar.Save(context.Background()))
// Use chi router with the real route pattern to test param name
// Submit empty JSON array
r := chi.NewRouter()
r.Post("/apps/{id}/env-vars/{varID}/delete", testCtx.handlers.HandleEnvVarDelete())
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env-vars/"+strconv.FormatInt(envVar.ID, 10)+"/delete",
nil,
"/apps/"+createdApp.ID+"/env",
strings.NewReader("[]"),
)
recorder := httptest.NewRecorder()
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code)
assert.Equal(t, http.StatusOK, recorder.Code)
// Verify the env var was actually deleted
found, findErr := models.FindEnvVar(context.Background(), testCtx.database, envVar.ID)
require.NoError(t, findErr)
assert.Nil(t, found, "env var should be deleted when using correct route param")
// Verify all env vars are gone
envVars, err := models.FindEnvVarsByAppID(
context.Background(), testCtx.database, createdApp.ID,
)
require.NoError(t, err)
assert.Empty(t, envVars, "all env vars should be deleted")
}
// TestHandleVolumeAddValidatesPaths verifies that HandleVolumeAdd validates

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

@@ -1,4 +1,3 @@
//nolint:dupl // Active Record pattern - similar structure to label.go is intentional
package models
import (
@@ -129,13 +128,48 @@ func FindEnvVarsByAppID(
return envVars, rows.Err()
}
// DeleteEnvVarsByAppID deletes all env vars for an app.
func DeleteEnvVarsByAppID(
// EnvVarPair is a key-value pair for bulk env var operations.
type EnvVarPair struct {
Key string
Value string
}
// ReplaceEnvVarsByAppID atomically replaces all env vars for an app
// within a single database transaction. It deletes all existing env
// vars and inserts the provided pairs. If any operation fails, the
// entire transaction is rolled back.
func ReplaceEnvVarsByAppID(
ctx context.Context,
db *database.Database,
appID string,
pairs []EnvVarPair,
) error {
_, err := db.Exec(ctx, "DELETE FROM app_env_vars WHERE app_id = ?", appID)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("beginning transaction: %w", err)
}
return err
defer func() { _ = tx.Rollback() }()
_, err = tx.ExecContext(ctx, "DELETE FROM app_env_vars WHERE app_id = ?", appID)
if err != nil {
return fmt.Errorf("deleting env vars: %w", err)
}
for _, p := range pairs {
_, err = tx.ExecContext(ctx,
"INSERT INTO app_env_vars (app_id, key, value) VALUES (?, ?, ?)",
appID, p.Key, p.Value,
)
if err != nil {
return fmt.Errorf("inserting env var %q: %w", p.Key, err)
}
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("committing transaction: %w", err)
}
return nil
}

View File

@@ -1,4 +1,3 @@
//nolint:dupl // Active Record pattern - similar structure to env_var.go is intentional
package models
import (

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

@@ -82,10 +82,8 @@ func (s *Server) SetupRoutes() {
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
// Environment variables
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
// Environment variables (monolithic bulk save)
r.Post("/apps/{id}/env", s.handlers.HandleEnvVarSave())
// Labels
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())

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,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
}

View File

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

View File

@@ -6,6 +6,103 @@
*/
document.addEventListener("alpine:init", () => {
// ============================================
// Environment Variable Editor Component
// ============================================
Alpine.data("envVarEditor", (appId) => ({
vars: [],
editIdx: -1,
editKey: "",
editVal: "",
appId: appId,
init() {
this.vars = Array.from(this.$el.querySelectorAll(".env-init")).map(
(span) => ({
key: span.dataset.key,
value: span.dataset.value,
}),
);
},
startEdit(i) {
this.editIdx = i;
this.editKey = this.vars[i].key;
this.editVal = this.vars[i].value;
},
saveEdit(i) {
this.vars[i] = { key: this.editKey, value: this.editVal };
this.editIdx = -1;
this.submitAll();
},
removeVar(i) {
if (!window.confirm("Delete this environment variable?")) {
return;
}
this.vars.splice(i, 1);
this.submitAll();
},
addVar(keyEl, valEl) {
const k = keyEl.value.trim();
const v = valEl.value.trim();
if (!k) {
return;
}
this.vars.push({ key: k, value: v });
this.submitAll();
},
submitAll() {
const csrfInput = this.$el.querySelector(
'input[name="gorilla.csrf.Token"]',
);
const csrfToken = csrfInput ? csrfInput.value : "";
fetch("/apps/" + this.appId + "/env", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify(
this.vars.map((e) => ({ key: e.key, value: e.value })),
),
})
.then((res) => {
if (res.ok) {
window.location.reload();
return;
}
res.json()
.then((data) => {
window.alert(
data.error ||
"Failed to save environment variables.",
);
})
.catch(() => {
window.alert(
"Failed to save environment variables.",
);
});
})
.catch(() => {
window.alert(
"Network error: could not save environment variables.",
);
});
},
}));
// ============================================
// App Detail Page Component
// ============================================
Alpine.data("appDetail", (config) => ({
appId: config.appId,
currentDeploymentId: config.initialDeploymentId,

View File

@@ -101,9 +101,10 @@
</div>
<!-- Environment Variables -->
<div class="card p-6 mb-6">
<div class="card p-6 mb-6" x-data="envVarEditor('{{.App.ID}}')">
<h2 class="section-title mb-4">Environment Variables</h2>
{{if .EnvVars}}
{{range .EnvVars}}<span class="env-init hidden" data-key="{{.Key}}" data-value="{{.Value}}"></span>{{end}}
<template x-if="vars.length > 0">
<div class="overflow-x-auto mb-4">
<table class="table">
<thead class="table-header">
@@ -114,47 +115,43 @@
</tr>
</thead>
<tbody class="table-body">
{{range .EnvVars}}
<tr x-data="{ editing: false }">
<template x-if="!editing">
<td class="font-mono font-medium">{{.Key}}</td>
<template x-for="(env, idx) in vars" :key="idx">
<tr>
<template x-if="editIdx !== idx">
<td class="font-mono font-medium" x-text="env.key"></td>
</template>
<template x-if="!editing">
<td class="font-mono text-gray-500">{{.Value}}</td>
<template x-if="editIdx !== idx">
<td class="font-mono text-gray-500" x-text="env.value"></td>
</template>
<template x-if="!editing">
<template x-if="editIdx !== idx">
<td class="text-right">
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
{{ $.CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form>
<button @click="startEdit(idx)" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
<button @click="removeVar(idx)" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</td>
</template>
<template x-if="editing">
<template x-if="editIdx === idx">
<td colspan="3">
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/edit" class="flex gap-2 items-center">
{{ $.CSRFField }}
<input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">
<form @submit.prevent="saveEdit(idx)" class="flex gap-2 items-center">
<input type="text" x-model="editKey" required class="input flex-1 font-mono text-sm">
<input type="text" x-model="editVal" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
<button type="button" @click="editIdx = -1" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form>
<p class="text-xs text-amber-600 mt-1">⚠ Container restart needed after env var changes.</p>
</td>
</template>
</tr>
{{end}}
</template>
</tbody>
</table>
</div>
{{end}}
<form method="POST" action="/apps/{{.App.ID}}/env" class="flex flex-col sm:flex-row gap-2">
{{ .CSRFField }}
<input type="text" name="key" placeholder="KEY" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" placeholder="value" required class="input flex-1 font-mono text-sm">
</template>
<form @submit.prevent="addVar($refs.newKey, $refs.newVal)" class="flex flex-col sm:flex-row gap-2">
<input x-ref="newKey" type="text" placeholder="KEY" required class="input flex-1 font-mono text-sm">
<input x-ref="newVal" type="text" placeholder="value" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary">Add</button>
</form>
<div class="hidden">{{ .CSRFField }}</div>
</div>
<!-- Labels -->

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>
<div class="flex justify-end gap-3 pt-4">
<a href="/apps/{{.App.ID}}" class="btn-secondary">Cancel</a>
<button type="submit" class="btn-primary">Save Changes</button>

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>