Compare commits

..

3 Commits

Author SHA1 Message Date
d22daf1f0a Merge branch 'main' into fix/js-formatting
All checks were successful
Check / check (pull_request) Successful in 4s
2026-03-10 18:54:08 +01:00
602046b329 Merge branch 'main' into fix/js-formatting
All checks were successful
Check / check (pull_request) Successful in 4s
2026-03-10 01:07:26 +01:00
clawbot
7920e723a6 style: run make fmt on JS static files
All checks were successful
Check / check (pull_request) Successful in 5s
2026-03-09 17:01:10 -07:00
18 changed files with 258 additions and 821 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 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, volume mounts, and custom health checks per app - Environment variables, labels, and volume mounts 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

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

View File

@@ -13,7 +13,6 @@ 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"
@@ -139,14 +138,13 @@ 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
HealthcheckCommand string // Custom health check shell command (empty = use image default)
} }
// VolumeMount represents a volume mount. // VolumeMount represents a volume mount.
@@ -187,29 +185,6 @@ func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
return exposedPorts, portBindings 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. // CreateContainer creates a new container.
func (c *Client) CreateContainer( func (c *Client) CreateContainer(
ctx context.Context, ctx context.Context,
@@ -243,22 +218,14 @@ func (c *Client) CreateContainer(
// Convert ports to exposed ports and port bindings // 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 // Create container
resp, err := c.docker.ContainerCreate(ctx, resp, err := c.docker.ContainerCreate(ctx,
containerConfig, &container.Config{
Image: opts.Image,
Env: envSlice,
Labels: opts.Labels,
ExposedPorts: exposedPorts,
},
&container.HostConfig{ &container.HostConfig{
Mounts: mounts, Mounts: mounts,
PortBindings: portBindings, PortBindings: portBindings,

View File

@@ -4,7 +4,6 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"testing" "testing"
"time"
) )
func TestValidBranchRegex(t *testing.T) { func TestValidBranchRegex(t *testing.T) {
@@ -147,52 +146,3 @@ 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,17 +57,15 @@ 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 == "" {
@@ -104,14 +102,13 @@ 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 {
@@ -211,11 +208,6 @@ 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()
@@ -265,10 +257,24 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
application.RepoURL = request.FormValue("repo_url") application.RepoURL = request.FormValue("repo_url")
application.Branch = request.FormValue("branch") application.Branch = request.FormValue("branch")
application.DockerfilePath = request.FormValue("dockerfile_path") application.DockerfilePath = request.FormValue("dockerfile_path")
application.DockerNetwork = optionalNullString(request.FormValue("docker_network"))
application.NtfyTopic = optionalNullString(request.FormValue("ntfy_topic")) if network := request.FormValue("docker_network"); network != "" {
application.SlackWebhook = optionalNullString(request.FormValue("slack_webhook")) application.DockerNetwork = sql.NullString{String: network, Valid: true}
application.HealthcheckCommand = optionalNullString(request.FormValue("healthcheck_command")) } 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{}
}
saveErr := application.Save(request.Context()) saveErr := application.Save(request.Context())
if saveErr != nil { if saveErr != nil {
@@ -897,92 +903,50 @@ func (h *Handlers) addKeyValueToApp(
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
} }
// envPairJSON represents a key-value pair in the JSON request body. // HandleEnvVarAdd handles adding an environment variable.
type envPairJSON struct { func (h *Handlers) HandleEnvVarAdd() http.HandlerFunc {
Key string `json:"key"` return func(writer http.ResponseWriter, request *http.Request) {
Value string `json:"value"` 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
// envVarMaxBodyBytes is the maximum allowed request body size for env var saves (1 MB). return envVar.Save(ctx)
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. // HandleEnvVarDelete handles deleting an environment variable.
// It reads a JSON array of {key, value} objects from the request body, func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc {
// 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) { return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id") appID := chi.URLParam(request, "id")
envVarIDStr := chi.URLParam(request, "varID")
application, findErr := models.FindApp(request.Context(), h.db, appID) envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
if findErr != nil || application == nil { if parseErr != nil {
http.NotFound(writer, request) http.NotFound(writer, request)
return return
} }
// Limit request body size to prevent abuse envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
request.Body = http.MaxBytesReader(writer, request.Body, envVarMaxBodyBytes) if findErr != nil || envVar == nil || envVar.AppID != appID {
http.NotFound(writer, request)
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 return
} }
modelPairs, validationErr := validateEnvPairs(pairs) deleteErr := envVar.Delete(request.Context())
if validationErr != "" { if deleteErr != nil {
h.respondJSON(writer, request, map[string]string{ h.log.Error("failed to delete env var", "error", deleteErr)
"error": validationErr,
}, http.StatusBadRequest)
return
} }
replaceErr := models.ReplaceEnvVarsByAppID( http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
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)
} }
} }
@@ -1241,6 +1205,59 @@ func ValidateVolumePath(p string) error {
return nil 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. // HandleLabelEdit handles editing an existing label.
func (h *Handlers) HandleLabelEdit() http.HandlerFunc { func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {

View File

@@ -560,242 +560,45 @@ func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) {
cfg.verifyFn(t, testCtx, resourceID) cfg.verifyFn(t, testCtx, resourceID)
} }
// TestHandleEnvVarSaveBulk tests that HandleEnvVarSave replaces all env vars // TestDeleteEnvVarOwnershipVerification tests that deleting an env var
// for an app with the submitted set (monolithic delete-all + insert-all). // via another app's URL path returns 404 (IDOR prevention).
func TestHandleEnvVarSaveBulk(t *testing.T) { func TestDeleteEnvVarOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern
t.Parallel() t.Parallel()
testCtx := setupTestHandlers(t) testOwnershipVerification(t, ownedResourceTestConfig{
createdApp := createTestApp(t, testCtx, "envvar-bulk-app") appPrefix1: "envvar-owner-app",
appPrefix2: "envvar-other-app",
createFn: func(t *testing.T, tc *testContext, ownerApp *models.App) int64 {
t.Helper()
// Create some pre-existing env vars envVar := models.NewEnvVar(tc.database)
for _, kv := range [][2]string{{"OLD_KEY", "old_value"}, {"REMOVE_ME", "gone"}} { envVar.AppID = ownerApp.ID
ev := models.NewEnvVar(testCtx.database) envVar.Key = "SECRET"
ev.AppID = createdApp.ID envVar.Value = "hunter2"
ev.Key = kv[0] require.NoError(t, envVar.Save(context.Background()))
ev.Value = kv[1]
require.NoError(t, ev.Save(context.Background()))
}
// Submit a new set as a JSON array of key/value objects return envVar.ID
body := `[{"key":"NEW_KEY","value":"new_value"},{"key":"ANOTHER","value":"42"}]` },
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()
r := chi.NewRouter() found, findErr := models.FindEnvVar(context.Background(), tc.database, resourceID)
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) require.NoError(t, findErr)
assert.NotNil(t, found, "env var should still exist after IDOR attempt")
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 // TestDeleteLabelOwnershipVerification tests that deleting a label
// via another app's URL path returns 404 (IDOR prevention). // via another app's URL path returns 404 (IDOR prevention).
func TestDeleteLabelOwnershipVerification(t *testing.T) { func TestDeleteLabelOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern
t.Parallel() t.Parallel()
testOwnershipVerification(t, ownedResourceTestConfig{ testOwnershipVerification(t, ownedResourceTestConfig{
@@ -911,43 +714,41 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
assert.NotNil(t, found, "port should still exist after IDOR attempt") assert.NotNil(t, found, "port should still exist after IDOR attempt")
} }
// TestHandleEnvVarSaveEmptyClears verifies that submitting an empty JSON // TestHandleEnvVarDeleteUsesCorrectRouteParam verifies that HandleEnvVarDelete
// array deletes all existing env vars for the app. // reads the "varID" chi URL parameter (matching the route definition {varID}),
func TestHandleEnvVarSaveEmptyClears(t *testing.T) { // not a mismatched name like "envID".
func TestHandleEnvVarDeleteUsesCorrectRouteParam(t *testing.T) {
t.Parallel() t.Parallel()
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-clear-app")
// Create a pre-existing env var createdApp := createTestApp(t, testCtx, "envdelete-param-app")
ev := models.NewEnvVar(testCtx.database)
ev.AppID = createdApp.ID
ev.Key = "DELETE_ME"
ev.Value = "gone"
require.NoError(t, ev.Save(context.Background()))
// Submit empty JSON array 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
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) r.Post("/apps/{id}/env-vars/{varID}/delete", testCtx.handlers.HandleEnvVarDelete())
request := httptest.NewRequest( request := httptest.NewRequest(
http.MethodPost, http.MethodPost,
"/apps/"+createdApp.ID+"/env", "/apps/"+createdApp.ID+"/env-vars/"+strconv.FormatInt(envVar.ID, 10)+"/delete",
strings.NewReader("[]"), nil,
) )
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request) r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, http.StatusSeeOther, recorder.Code)
// Verify all env vars are gone // Verify the env var was actually deleted
envVars, err := models.FindEnvVarsByAppID( found, findErr := models.FindEnvVar(context.Background(), testCtx.database, envVar.ID)
context.Background(), testCtx.database, createdApp.ID, require.NoError(t, findErr)
) assert.Nil(t, found, "env var should be deleted when using correct route param")
require.NoError(t, err)
assert.Empty(t, envVars, "all env vars should be deleted")
} }
// TestHandleVolumeAddValidatesPaths verifies that HandleVolumeAdd validates // TestHandleVolumeAddValidatesPaths verifies that HandleVolumeAdd validates

View File

@@ -14,7 +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, healthcheck_command, created_at, updated_at` previous_image_id, created_at, updated_at`
// AppStatus represents the status of an app. // AppStatus represents the status of an app.
type AppStatus string type AppStatus string
@@ -32,24 +32,23 @@ 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
HealthcheckCommand sql.NullString 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.
@@ -143,14 +142,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, healthcheck_command previous_image_id
) 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.HealthcheckCommand, a.PreviousImageID,
) )
if err != nil { if err != nil {
return err return err
@@ -165,7 +164,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 = ?, healthcheck_command = ?, previous_image_id = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ?` WHERE id = ?`
@@ -173,7 +172,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.HealthcheckCommand, a.PreviousImageID,
a.ID, a.ID,
) )
@@ -188,7 +187,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.HealthcheckCommand, &a.PreviousImageID,
&a.CreatedAt, &a.UpdatedAt, &a.CreatedAt, &a.UpdatedAt,
) )
} }
@@ -206,7 +205,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.HealthcheckCommand, &app.PreviousImageID,
&app.CreatedAt, &app.UpdatedAt, &app.CreatedAt, &app.UpdatedAt,
) )
if scanErr != nil { if scanErr != nil {

View File

@@ -1,3 +1,4 @@
//nolint:dupl // Active Record pattern - similar structure to label.go is intentional
package models package models
import ( import (
@@ -128,48 +129,13 @@ func FindEnvVarsByAppID(
return envVars, rows.Err() return envVars, rows.Err()
} }
// EnvVarPair is a key-value pair for bulk env var operations. // DeleteEnvVarsByAppID deletes all env vars for an app.
type EnvVarPair struct { func DeleteEnvVarsByAppID(
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, ctx context.Context,
db *database.Database, db *database.Database,
appID string, appID string,
pairs []EnvVarPair,
) error { ) error {
tx, err := db.BeginTx(ctx, nil) _, err := db.Exec(ctx, "DELETE FROM app_env_vars WHERE app_id = ?", appID)
if err != nil {
return fmt.Errorf("beginning transaction: %w", err)
}
defer func() { _ = tx.Rollback() }() return err
_, 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,3 +1,4 @@
//nolint:dupl // Active Record pattern - similar structure to env_var.go is intentional
package models package models
import ( import (

View File

@@ -704,72 +704,6 @@ 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

View File

@@ -82,8 +82,10 @@ func (s *Server) SetupRoutes() {
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop()) r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
r.Post("/apps/{id}/start", s.handlers.HandleAppStart()) r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
// Environment variables (monolithic bulk save) // Environment variables
r.Post("/apps/{id}/env", s.handlers.HandleEnvVarSave()) 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())
// Labels // Labels
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd()) r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())

View File

@@ -46,14 +46,13 @@ 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.
@@ -101,10 +100,6 @@ 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)
@@ -117,14 +112,13 @@ 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.
@@ -150,10 +144,6 @@ 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,20 +1094,14 @@ func (svc *Service) buildContainerOptions(
network = app.DockerNetwork.String network = app.DockerNetwork.String
} }
healthcheckCmd := ""
if app.HealthcheckCommand.Valid {
healthcheckCmd = app.HealthcheckCommand.String
}
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,
HealthcheckCommand: healthcheckCmd,
}, nil }, nil
} }

View File

@@ -2,7 +2,6 @@ package deploy_test
import ( import (
"context" "context"
"database/sql"
"log/slog" "log/slog"
"os" "os"
"testing" "testing"
@@ -44,64 +43,3 @@ func TestBuildContainerOptionsUsesImageID(t *testing.T) {
t.Errorf("expected Name=%q, got %q", "upaas-myapp", opts.Name) 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,103 +6,6 @@
*/ */
document.addEventListener("alpine:init", () => { 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) => ({ Alpine.data("appDetail", (config) => ({
appId: config.appId, appId: config.appId,
currentDeploymentId: config.initialDeploymentId, currentDeploymentId: config.initialDeploymentId,

View File

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

View File

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

View File

@@ -117,19 +117,6 @@
> >
</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>