Compare commits

..

3 Commits

Author SHA1 Message Date
d22daf1f0a Merge branch 'main' into fix/js-formatting 2026-03-10 18:54:08 +01:00
602046b329 Merge branch 'main' into fix/js-formatting 2026-03-10 01:07:26 +01:00
clawbot
7920e723a6 style: run make fmt on JS static files 2026-03-09 17:01:10 -07:00
23 changed files with 275 additions and 1541 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 UUID-based webhook URLs for Gitea integration
- Branch filtering - only deploy on configured branch changes
- Environment variables, labels, volume mounts, and custom health checks per app
- CPU and memory resource limits per app
- Environment variables, labels, and volume mounts per app
- Docker builds via socket access
- Notifications via ntfy and Slack-compatible webhooks
- Simple server-rendered UI with Tailwind CSS

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

@@ -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"
"strconv"
"strings"
"time"
dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
@@ -139,16 +138,13 @@ 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
CPULimit float64 // CPU cores (e.g. 0.5 = half a core, 2.0 = two cores). 0 means unlimited.
MemoryLimit int64 // Memory in bytes. 0 means unlimited.
HealthcheckCommand string // Custom health check shell command (empty = use image default)
Name string
Image string
Env map[string]string
Labels map[string]string
Volumes []VolumeMount
Ports []PortMapping
Network string
}
// VolumeMount represents a volume mount.
@@ -165,14 +161,6 @@ type PortMapping struct {
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.
func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
exposedPorts := make(nat.PortSet)
@@ -197,71 +185,6 @@ func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
return exposedPorts, portBindings
}
// buildEnvSlice converts an env map to a Docker-compatible env slice.
func buildEnvSlice(env map[string]string) []string {
envSlice := make([]string, 0, len(env))
for key, val := range env {
envSlice = append(envSlice, key+"="+val)
}
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
}
// 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,
@@ -273,29 +196,40 @@ func (c *Client) CreateContainer(
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)
// Build container config
containerConfig := &container.Config{
Image: opts.Image,
Env: buildEnvSlice(opts.Env),
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,
containerConfig,
&container.Config{
Image: opts.Image,
Env: envSlice,
Labels: opts.Labels,
ExposedPorts: exposedPorts,
},
&container.HostConfig{
Mounts: buildMounts(opts.Volumes),
Mounts: mounts,
PortBindings: portBindings,
NetworkMode: container.NetworkMode(opts.Network),
Resources: buildResources(opts),
RestartPolicy: container.RestartPolicy{
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,7 +4,6 @@ import (
"errors"
"log/slog"
"testing"
"time"
)
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")
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,
"HealthcheckCommand": healthcheckCommand,
"Name": name,
"RepoURL": repoURL,
"Branch": branch,
"DockerfilePath": dockerfilePath,
"DockerNetwork": dockerNetwork,
"NtfyTopic": ntfyTopic,
"SlackWebhook": slackWebhook,
}, request)
if name == "" || repoURL == "" {
@@ -104,14 +102,13 @@ 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,
HealthcheckCommand: healthcheckCommand,
Name: name,
RepoURL: repoURL,
Branch: branch,
DockerfilePath: dockerfilePath,
DockerNetwork: dockerNetwork,
NtfyTopic: ntfyTopic,
SlackWebhook: slackWebhook,
},
)
if createErr != nil {
@@ -260,20 +257,23 @@ 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")
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"))
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)
if network := request.FormValue("docker_network"); network != "" {
application.DockerNetwork = sql.NullString{String: network, Valid: true}
} else {
application.DockerNetwork = sql.NullString{}
}
return
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())
@@ -903,92 +903,50 @@ func (h *Handlers) addKeyValueToApp(
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
}
// envPairJSON represents a key-value pair in the JSON request body.
type envPairJSON struct {
Key string `json:"key"`
Value string `json:"value"`
}
// 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
// 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 envVar.Save(ctx)
},
)
}
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 {
// HandleEnvVarDelete handles deleting an environment variable.
func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
envVarIDStr := chi.URLParam(request, "varID")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
// 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)
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
if findErr != nil || envVar == nil || envVar.AppID != appID {
http.NotFound(writer, request)
return
}
modelPairs, validationErr := validateEnvPairs(pairs)
if validationErr != "" {
h.respondJSON(writer, request, map[string]string{
"error": validationErr,
}, http.StatusBadRequest)
return
deleteErr := envVar.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete env var", "error", deleteErr)
}
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)
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
@@ -1247,6 +1205,59 @@ 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) {
@@ -1368,129 +1379,6 @@ func validateVolumePaths(hostPath, containerPath string) error {
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.
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {

View File

@@ -560,242 +560,45 @@ func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) {
cfg.verifyFn(t, testCtx, resourceID)
}
// 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) {
// 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
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-bulk-app")
testOwnershipVerification(t, ownedResourceTestConfig{
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
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()))
}
envVar := models.NewEnvVar(tc.database)
envVar.AppID = ownerApp.ID
envVar.Key = "SECRET"
envVar.Value = "hunter2"
require.NoError(t, envVar.Save(context.Background()))
// Submit a new set as a JSON array of key/value objects
body := `[{"key":"NEW_KEY","value":"new_value"},{"key":"ANOTHER","value":"42"}]`
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()
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")
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")
},
})
}
// TestDeleteLabelOwnershipVerification tests that deleting a label
// 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()
testOwnershipVerification(t, ownedResourceTestConfig{
@@ -911,43 +714,41 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
assert.NotNil(t, found, "port should still exist after IDOR attempt")
}
// TestHandleEnvVarSaveEmptyClears verifies that submitting an empty JSON
// array deletes all existing env vars for the app.
func TestHandleEnvVarSaveEmptyClears(t *testing.T) {
// 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) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-clear-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()))
createdApp := createTestApp(t, testCtx, "envdelete-param-app")
// 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.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
r.Post("/apps/{id}/env-vars/{varID}/delete", testCtx.handlers.HandleEnvVarDelete())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader("[]"),
"/apps/"+createdApp.ID+"/env-vars/"+strconv.FormatInt(envVar.ID, 10)+"/delete",
nil,
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, http.StatusSeeOther, recorder.Code)
// 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")
// 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")
}
// TestHandleVolumeAddValidatesPaths verifies that HandleVolumeAdd validates

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,
ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
previous_image_id, cpu_limit, memory_limit,
healthcheck_command, created_at, updated_at`
previous_image_id, created_at, updated_at`
// AppStatus represents the status of an app.
type AppStatus string
@@ -33,26 +32,23 @@ 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
CPULimit sql.NullFloat64
MemoryLimit sql.NullInt64
HealthcheckCommand 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
CreatedAt time.Time
UpdatedAt time.Time
}
// NewApp creates a new App with a database reference.
@@ -146,16 +142,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, cpu_limit, memory_limit,
healthcheck_command
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
previous_image_id
) 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.CPULimit, a.MemoryLimit,
a.HealthcheckCommand,
a.PreviousImageID,
)
if err != nil {
return err
@@ -170,9 +164,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 = ?,
cpu_limit = ?, memory_limit = ?,
healthcheck_command = ?,
previous_image_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`
@@ -181,8 +173,6 @@ previous_image_id = ?,
a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
a.PreviousImageID,
a.CPULimit, a.MemoryLimit,
a.HealthcheckCommand,
a.ID,
)
@@ -198,8 +188,6 @@ func (a *App) scan(row *sql.Row) error {
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
&a.WebhookSecretHash,
&a.PreviousImageID,
&a.CPULimit, &a.MemoryLimit,
&a.HealthcheckCommand,
&a.CreatedAt, &a.UpdatedAt,
)
}
@@ -218,8 +206,6 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
&app.WebhookSecretHash,
&app.PreviousImageID,
&app.CPULimit, &app.MemoryLimit,
&app.HealthcheckCommand,
&app.CreatedAt, &app.UpdatedAt,
)
if scanErr != nil {

View File

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

View File

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

View File

@@ -704,72 +704,6 @@ 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
@@ -847,96 +781,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.
func createTestApp(t *testing.T, testDB *database.Database) *models.App {
t.Helper()

View File

@@ -82,8 +82,10 @@ func (s *Server) SetupRoutes() {
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
// Environment variables (monolithic bulk save)
r.Post("/apps/{id}/env", s.handlers.HandleEnvVarSave())
// 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())
// Labels
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.
type CreateAppInput struct {
Name string
RepoURL string
Branch string
DockerfilePath string
DockerNetwork string
NtfyTopic string
SlackWebhook string
HealthcheckCommand string
Name string
RepoURL string
Branch string
DockerfilePath string
DockerNetwork string
NtfyTopic string
SlackWebhook string
}
// 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}
}
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)
@@ -117,14 +112,13 @@ 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
HealthcheckCommand string
Name string
RepoURL string
Branch string
DockerfilePath string
DockerNetwork string
NtfyTopic string
SlackWebhook string
}
// UpdateApp updates an existing application.
@@ -150,10 +144,6 @@ 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,44 +1094,17 @@ func (svc *Service) buildContainerOptions(
network = app.DockerNetwork.String
}
cpuLimit, memoryLimit := extractResourceLimits(app)
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,
CPULimit: cpuLimit,
MemoryLimit: memoryLimit,
HealthcheckCommand: healthcheckCmd,
Name: "upaas-" + app.Name,
Image: imageID.String(),
Env: envMap,
Labels: buildLabelMap(app, labels),
Volumes: buildVolumeMounts(volumes),
Ports: buildPortMappings(ports),
Network: network,
}, nil
}
// extractResourceLimits returns CPU and memory limits from the app model.
func extractResourceLimits(app *models.App) (float64, int64) {
var cpuLimit float64
if app.CPULimit.Valid {
cpuLimit = app.CPULimit.Float64
}
var memoryLimit int64
if app.MemoryLimit.Valid {
memoryLimit = app.MemoryLimit.Int64
}
return cpuLimit, memoryLimit
}
func buildLabelMap(app *models.App, labels []*models.Label) map[string]string {
labelMap := make(map[string]string, len(labels)+upaasLabelCount)
for _, label := range labels {

View File

@@ -2,7 +2,6 @@ package deploy_test
import (
"context"
"database/sql"
"log/slog"
"os"
"testing"
@@ -44,154 +43,3 @@ func TestBuildContainerOptionsUsesImageID(t *testing.T) {
t.Errorf("expected Name=%q, got %q", "upaas-myapp", opts.Name)
}
}
func TestBuildContainerOptionsNoResourceLimits(t *testing.T) {
t.Parallel()
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = "nolimits"
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.CPULimit != 0 {
t.Errorf("expected CPULimit=0, got %v", opts.CPULimit)
}
if opts.MemoryLimit != 0 {
t.Errorf("expected MemoryLimit=0, got %v", opts.MemoryLimit)
}
}
func TestBuildContainerOptionsCPULimit(t *testing.T) {
t.Parallel()
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = "cpulimit"
app.CPULimit = sql.NullFloat64{Float64: 0.5, 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, docker.ImageID("test:latest"),
)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
if opts.CPULimit != 0.5 {
t.Errorf("expected CPULimit=0.5, got %v", opts.CPULimit)
}
}
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)
}
}
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", () => {
// ============================================
// 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,10 +101,9 @@
</div>
<!-- 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>
{{range .EnvVars}}<span class="env-init hidden" data-key="{{.Key}}" data-value="{{.Value}}"></span>{{end}}
<template x-if="vars.length > 0">
{{if .EnvVars}}
<div class="overflow-x-auto mb-4">
<table class="table">
<thead class="table-header">
@@ -115,43 +114,47 @@
</tr>
</thead>
<tbody class="table-body">
<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>
{{range .EnvVars}}
<tr x-data="{ editing: false }">
<template x-if="!editing">
<td class="font-mono font-medium">{{.Key}}</td>
</template>
<template x-if="editIdx !== idx">
<td class="font-mono text-gray-500" x-text="env.value"></td>
<template x-if="!editing">
<td class="font-mono text-gray-500">{{.Value}}</td>
</template>
<template x-if="editIdx !== idx">
<template x-if="!editing">
<td class="text-right">
<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>
<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>
</td>
</template>
<template x-if="editIdx === idx">
<template x-if="editing">
<td colspan="3">
<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">
<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">
<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>
<p class="text-xs text-amber-600 mt-1">⚠ Container restart needed after env var changes.</p>
</td>
</template>
</tr>
</template>
{{end}}
</tbody>
</table>
</div>
</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">
{{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">
<button type="submit" class="btn-primary">Add</button>
</form>
<div class="hidden">{{ .CSRFField }}</div>
</div>
<!-- Labels -->

View File

@@ -114,51 +114,6 @@
>
</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>
<hr class="border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Resource Limits</h3>
<div class="grid grid-cols-2 gap-4">
<div class="form-group">
<label for="cpu_limit" class="label">CPU Limit (cores)</label>
<input
type="text"
id="cpu_limit"
name="cpu_limit"
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 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,19 +117,6 @@
>
</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>

View File

@@ -6,7 +6,6 @@ import (
"fmt"
"html/template"
"io"
"strconv"
"sync"
)
@@ -24,34 +23,6 @@ var (
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.
func initTemplates() {
templatesMutex.Lock()
@@ -61,10 +32,8 @@ func initTemplates() {
return
}
// Parse base template with shared components and custom functions
baseTemplate = template.Must(
template.New("base.html").Funcs(templateFuncs()).ParseFS(templatesRaw, "base.html"),
)
// Parse base template with shared components
baseTemplate = template.Must(template.ParseFS(templatesRaw, "base.html"))
// Pages that extend base
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)
}
})
}
}