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
13 changed files with 367 additions and 1598 deletions

View File

@@ -1,12 +1,12 @@
# µPaaS by [@sneak](https://sneak.berlin) # µPaaS by [@sneak](https://sneak.berlin)
A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via webhooks from Gitea, GitHub, or GitLab. A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via Gitea webhooks.
## Features ## Features
- Single admin user with argon2id password hashing - Single admin user with argon2id password hashing
- Per-app SSH keypairs for read-only deploy keys - Per-app SSH keypairs for read-only deploy keys
- Per-app UUID-based webhook URLs with auto-detection of Gitea, GitHub, and GitLab - Per-app UUID-based webhook URLs for Gitea integration
- Branch filtering - only deploy on configured branch changes - Branch filtering - only deploy on configured branch changes
- Environment variables, labels, and volume mounts per app - Environment variables, labels, and volume mounts per app
- Docker builds via socket access - Docker builds via socket access
@@ -19,7 +19,7 @@ A simple self-hosted PaaS that auto-deploys Docker containers from Git repositor
- Complex CI pipelines - Complex CI pipelines
- Multiple container orchestration - Multiple container orchestration
- SPA/API-first design - SPA/API-first design
- Support for non-push webhook events (e.g. issues, merge requests) - Support for non-Gitea webhooks
## Architecture ## Architecture
@@ -44,7 +44,7 @@ upaas/
│ │ ├── auth/ # Authentication service │ │ ├── auth/ # Authentication service
│ │ ├── deploy/ # Deployment orchestration │ │ ├── deploy/ # Deployment orchestration
│ │ ├── notify/ # Notifications (ntfy, Slack) │ │ ├── notify/ # Notifications (ntfy, Slack)
│ │ └── webhook/ # Webhook processing (Gitea, GitHub, GitLab) │ │ └── webhook/ # Gitea webhook processing
│ └── ssh/ # SSH key generation │ └── ssh/ # SSH key generation
├── static/ # Embedded CSS/JS assets ├── static/ # Embedded CSS/JS assets
└── templates/ # Embedded HTML templates └── templates/ # Embedded HTML templates

View File

@@ -903,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)
} }
} }
@@ -1247,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

@@ -7,14 +7,12 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/webhook"
) )
// maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB). // maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB).
const maxWebhookBodySize = 1 << 20 const maxWebhookBodySize = 1 << 20
// HandleWebhook handles incoming webhooks from Gitea, GitHub, or GitLab. // HandleWebhook handles incoming Gitea webhooks.
// The webhook source is auto-detected from HTTP headers.
func (h *Handlers) HandleWebhook() http.HandlerFunc { func (h *Handlers) HandleWebhook() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
secret := chi.URLParam(request, "secret") secret := chi.URLParam(request, "secret")
@@ -52,17 +50,16 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
return return
} }
// Auto-detect webhook source from headers // Get event type from header
source := webhook.DetectWebhookSource(request.Header) eventType := request.Header.Get("X-Gitea-Event")
if eventType == "" {
// Extract event type based on detected source eventType = "push"
eventType := webhook.DetectEventType(request.Header, source) }
// Process webhook // Process webhook
webhookErr := h.webhook.HandleWebhook( webhookErr := h.webhook.HandleWebhook(
request.Context(), request.Context(),
application, application,
source,
eventType, eventType,
body, body,
) )

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

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

@@ -1,248 +0,0 @@
package webhook
import "encoding/json"
// GiteaPushPayload represents a Gitea push webhook payload.
//
//nolint:tagliatelle // Field names match Gitea API (snake_case)
type GiteaPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL UnparsedURL `json:"compare_url"`
Repository struct {
FullName string `json:"full_name"`
CloneURL UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL UnparsedURL `json:"html_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
Email string `json:"email"`
} `json:"pusher"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// GitHubPushPayload represents a GitHub push webhook payload.
//
//nolint:tagliatelle // Field names match GitHub API (snake_case)
type GitHubPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL string `json:"compare"`
Repository struct {
FullName string `json:"full_name"`
CloneURL UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL UnparsedURL `json:"html_url"`
} `json:"repository"`
Pusher struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"pusher"`
HeadCommit *struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
} `json:"head_commit"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// GitLabPushPayload represents a GitLab push webhook payload.
//
//nolint:tagliatelle // Field names match GitLab API (snake_case)
type GitLabPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
Project struct {
PathWithNamespace string `json:"path_with_namespace"`
GitHTTPURL UnparsedURL `json:"git_http_url"`
GitSSHURL string `json:"git_ssh_url"`
WebURL UnparsedURL `json:"web_url"`
} `json:"project"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// ParsePushPayload parses a raw webhook payload into a normalized PushEvent
// based on the detected webhook source. Returns an error if JSON unmarshaling
// fails. For SourceUnknown, falls back to Gitea format for backward
// compatibility.
func ParsePushPayload(source Source, payload []byte) (*PushEvent, error) {
switch source {
case SourceGitHub:
return parseGitHubPush(payload)
case SourceGitLab:
return parseGitLabPush(payload)
case SourceGitea, SourceUnknown:
// Gitea and unknown both use Gitea format for backward compatibility.
return parseGiteaPush(payload)
}
// Unreachable for known source values, but satisfies exhaustive checker.
return parseGiteaPush(payload)
}
func parseGiteaPush(payload []byte) (*PushEvent, error) {
var p GiteaPushPayload
unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil {
return nil, unmarshalErr
}
commitURL := extractGiteaCommitURL(p)
return &PushEvent{
Source: SourceGitea,
Ref: p.Ref,
Before: p.Before,
After: p.After,
Branch: extractBranch(p.Ref),
RepoName: p.Repository.FullName,
CloneURL: p.Repository.CloneURL,
HTMLURL: p.Repository.HTMLURL,
CommitURL: commitURL,
Pusher: p.Pusher.Username,
}, nil
}
func parseGitHubPush(payload []byte) (*PushEvent, error) {
var p GitHubPushPayload
unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil {
return nil, unmarshalErr
}
commitURL := extractGitHubCommitURL(p)
return &PushEvent{
Source: SourceGitHub,
Ref: p.Ref,
Before: p.Before,
After: p.After,
Branch: extractBranch(p.Ref),
RepoName: p.Repository.FullName,
CloneURL: p.Repository.CloneURL,
HTMLURL: p.Repository.HTMLURL,
CommitURL: commitURL,
Pusher: p.Pusher.Name,
}, nil
}
func parseGitLabPush(payload []byte) (*PushEvent, error) {
var p GitLabPushPayload
unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil {
return nil, unmarshalErr
}
commitURL := extractGitLabCommitURL(p)
return &PushEvent{
Source: SourceGitLab,
Ref: p.Ref,
Before: p.Before,
After: p.After,
Branch: extractBranch(p.Ref),
RepoName: p.Project.PathWithNamespace,
CloneURL: p.Project.GitHTTPURL,
HTMLURL: p.Project.WebURL,
CommitURL: commitURL,
Pusher: p.UserName,
}, nil
}
// extractBranch extracts the branch name from a git ref.
func extractBranch(ref string) string {
// refs/heads/main -> main
const prefix = "refs/heads/"
if len(ref) >= len(prefix) && ref[:len(prefix)] == prefix {
return ref[len(prefix):]
}
return ref
}
// extractGiteaCommitURL extracts the commit URL from a Gitea push payload.
// Prefers the URL from the head commit, falls back to constructing from repo URL.
func extractGiteaCommitURL(payload GiteaPushPayload) UnparsedURL {
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
if payload.Repository.HTMLURL != "" && payload.After != "" {
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
}
return ""
}
// extractGitHubCommitURL extracts the commit URL from a GitHub push payload.
// Prefers head_commit.url, then searches commits, then constructs from repo URL.
func extractGitHubCommitURL(payload GitHubPushPayload) UnparsedURL {
if payload.HeadCommit != nil && payload.HeadCommit.URL != "" {
return payload.HeadCommit.URL
}
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
if payload.Repository.HTMLURL != "" && payload.After != "" {
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
}
return ""
}
// extractGitLabCommitURL extracts the commit URL from a GitLab push payload.
// Prefers commit URL from the commits list, falls back to constructing from
// project web URL.
func extractGitLabCommitURL(payload GitLabPushPayload) UnparsedURL {
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
if payload.Project.WebURL != "" && payload.After != "" {
return UnparsedURL(payload.Project.WebURL.String() + "/-/commit/" + payload.After)
}
return ""
}

View File

@@ -1,7 +1,5 @@
package webhook package webhook
import "net/http"
// UnparsedURL is a URL stored as a plain string without parsing. // UnparsedURL is a URL stored as a plain string without parsing.
// Use this instead of string when the value is known to be a URL // Use this instead of string when the value is known to be a URL
// but should not be parsed into a net/url.URL (e.g. webhook URLs, // but should not be parsed into a net/url.URL (e.g. webhook URLs,
@@ -10,84 +8,3 @@ type UnparsedURL string
// String implements the fmt.Stringer interface. // String implements the fmt.Stringer interface.
func (u UnparsedURL) String() string { return string(u) } func (u UnparsedURL) String() string { return string(u) }
// Source identifies which git hosting platform sent the webhook.
type Source string
const (
// SourceGitea indicates the webhook was sent by a Gitea instance.
SourceGitea Source = "gitea"
// SourceGitHub indicates the webhook was sent by GitHub.
SourceGitHub Source = "github"
// SourceGitLab indicates the webhook was sent by a GitLab instance.
SourceGitLab Source = "gitlab"
// SourceUnknown indicates the webhook source could not be determined.
SourceUnknown Source = "unknown"
)
// String implements the fmt.Stringer interface.
func (s Source) String() string { return string(s) }
// DetectWebhookSource determines the webhook source from HTTP headers.
// It checks for platform-specific event headers in this order:
// Gitea (X-Gitea-Event), GitHub (X-GitHub-Event), GitLab (X-Gitlab-Event).
// Returns SourceUnknown if no recognized header is found.
func DetectWebhookSource(headers http.Header) Source {
if headers.Get("X-Gitea-Event") != "" {
return SourceGitea
}
if headers.Get("X-Github-Event") != "" {
return SourceGitHub
}
if headers.Get("X-Gitlab-Event") != "" {
return SourceGitLab
}
return SourceUnknown
}
// DetectEventType extracts the event type string from HTTP headers
// based on the detected webhook source. Returns "push" as a fallback
// when no event header is found.
func DetectEventType(headers http.Header, source Source) string {
switch source {
case SourceGitea:
if v := headers.Get("X-Gitea-Event"); v != "" {
return v
}
case SourceGitHub:
if v := headers.Get("X-Github-Event"); v != "" {
return v
}
case SourceGitLab:
if v := headers.Get("X-Gitlab-Event"); v != "" {
return v
}
case SourceUnknown:
// Fall through to default
}
return "push"
}
// PushEvent is a normalized representation of a push webhook payload
// from any supported source (Gitea, GitHub, GitLab). The webhook
// service converts source-specific payloads into this format before
// processing.
type PushEvent struct {
Source Source
Ref string
Before string
After string
Branch string
RepoName string
CloneURL UnparsedURL
HTMLURL UnparsedURL
CommitURL UnparsedURL
Pusher string
}

View File

@@ -4,6 +4,7 @@ package webhook
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -43,46 +44,68 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
}, nil }, nil
} }
// HandleWebhook processes a webhook request from any supported source // GiteaPushPayload represents a Gitea push webhook payload.
// (Gitea, GitHub, or GitLab). The source parameter determines which //
// payload format to use for parsing. //nolint:tagliatelle // Field names match Gitea API (snake_case)
type GiteaPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL UnparsedURL `json:"compare_url"`
Repository struct {
FullName string `json:"full_name"`
CloneURL UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL UnparsedURL `json:"html_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
Email string `json:"email"`
} `json:"pusher"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// HandleWebhook processes a webhook request.
func (svc *Service) HandleWebhook( func (svc *Service) HandleWebhook(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
source Source,
eventType string, eventType string,
payload []byte, payload []byte,
) error { ) error {
svc.log.Info("processing webhook", svc.log.Info("processing webhook", "app", app.Name, "event", eventType)
"app", app.Name,
"source", source.String(),
"event", eventType,
)
// Parse payload into normalized push event // Parse payload
pushEvent, parseErr := ParsePushPayload(source, payload) var pushPayload GiteaPushPayload
if parseErr != nil {
svc.log.Warn("failed to parse webhook payload", unmarshalErr := json.Unmarshal(payload, &pushPayload)
"error", parseErr, if unmarshalErr != nil {
"source", source.String(), svc.log.Warn("failed to parse webhook payload", "error", unmarshalErr)
) // Continue anyway to log the event
// Continue with empty push event to still log the webhook
pushEvent = &PushEvent{Source: source}
} }
// Extract branch from ref
branch := extractBranch(pushPayload.Ref)
commitSHA := pushPayload.After
commitURL := extractCommitURL(pushPayload)
// Check if branch matches // Check if branch matches
matched := pushEvent.Branch == app.Branch matched := branch == app.Branch
// Create webhook event record // Create webhook event record
event := models.NewWebhookEvent(svc.db) event := models.NewWebhookEvent(svc.db)
event.AppID = app.ID event.AppID = app.ID
event.EventType = eventType event.EventType = eventType
event.Branch = pushEvent.Branch event.Branch = branch
event.CommitSHA = sql.NullString{String: pushEvent.After, Valid: pushEvent.After != ""} event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""}
event.CommitURL = sql.NullString{ event.CommitURL = sql.NullString{String: commitURL.String(), Valid: commitURL != ""}
String: pushEvent.CommitURL.String(),
Valid: pushEvent.CommitURL != "",
}
event.Payload = sql.NullString{String: string(payload), Valid: true} event.Payload = sql.NullString{String: string(payload), Valid: true}
event.Matched = matched event.Matched = matched
event.Processed = false event.Processed = false
@@ -94,10 +117,9 @@ func (svc *Service) HandleWebhook(
svc.log.Info("webhook event recorded", svc.log.Info("webhook event recorded",
"app", app.Name, "app", app.Name,
"source", source.String(), "branch", branch,
"branch", pushEvent.Branch,
"matched", matched, "matched", matched,
"commit", pushEvent.After, "commit", commitSHA,
) )
// If branch matches, trigger deployment // If branch matches, trigger deployment
@@ -132,3 +154,33 @@ func (svc *Service) triggerDeployment(
_ = event.Save(deployCtx) _ = event.Save(deployCtx)
}() }()
} }
// extractBranch extracts the branch name from a git ref.
func extractBranch(ref string) string {
// refs/heads/main -> main
const prefix = "refs/heads/"
if len(ref) >= len(prefix) && ref[:len(prefix)] == prefix {
return ref[len(prefix):]
}
return ref
}
// extractCommitURL extracts the commit URL from the webhook payload.
// Prefers the URL from the head commit, falls back to constructing from repo URL.
func extractCommitURL(payload GiteaPushPayload) UnparsedURL {
// Try to find the URL from the head commit (matching After SHA)
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
// Fall back to constructing URL from repo HTML URL
if payload.Repository.HTMLURL != "" && payload.After != "" {
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
}
return ""
}

View File

@@ -3,7 +3,6 @@ package webhook_test
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -103,114 +102,44 @@ func createTestApp(
return app return app
} }
// TestDetectWebhookSource tests auto-detection of webhook source from HTTP headers.
//
//nolint:funlen // table-driven test with comprehensive test cases //nolint:funlen // table-driven test with comprehensive test cases
func TestDetectWebhookSource(testingT *testing.T) { func TestExtractBranch(testingT *testing.T) {
testingT.Parallel() testingT.Parallel()
tests := []struct { tests := []struct {
name string name string
headers map[string]string ref string
expected webhook.Source
}{
{
name: "detects Gitea from X-Gitea-Event header",
headers: map[string]string{"X-Gitea-Event": "push"},
expected: webhook.SourceGitea,
},
{
name: "detects GitHub from X-GitHub-Event header",
headers: map[string]string{"X-GitHub-Event": "push"},
expected: webhook.SourceGitHub,
},
{
name: "detects GitLab from X-Gitlab-Event header",
headers: map[string]string{"X-Gitlab-Event": "Push Hook"},
expected: webhook.SourceGitLab,
},
{
name: "returns unknown when no recognized header",
headers: map[string]string{"Content-Type": "application/json"},
expected: webhook.SourceUnknown,
},
{
name: "returns unknown for empty headers",
headers: map[string]string{},
expected: webhook.SourceUnknown,
},
{
name: "Gitea takes precedence over GitHub",
headers: map[string]string{
"X-Gitea-Event": "push",
"X-GitHub-Event": "push",
},
expected: webhook.SourceGitea,
},
{
name: "GitHub takes precedence over GitLab",
headers: map[string]string{
"X-GitHub-Event": "push",
"X-Gitlab-Event": "Push Hook",
},
expected: webhook.SourceGitHub,
},
}
for _, testCase := range tests {
testingT.Run(testCase.name, func(t *testing.T) {
t.Parallel()
headers := http.Header{}
for key, value := range testCase.headers {
headers.Set(key, value)
}
result := webhook.DetectWebhookSource(headers)
assert.Equal(t, testCase.expected, result)
})
}
}
// TestDetectEventType tests event type extraction from HTTP headers.
func TestDetectEventType(testingT *testing.T) {
testingT.Parallel()
tests := []struct {
name string
headers map[string]string
source webhook.Source
expected string expected string
}{ }{
{ {
name: "extracts Gitea event type", name: "extracts main branch",
headers: map[string]string{"X-Gitea-Event": "push"}, ref: "refs/heads/main",
source: webhook.SourceGitea, expected: "main",
expected: "push",
}, },
{ {
name: "extracts GitHub event type", name: "extracts feature branch",
headers: map[string]string{"X-GitHub-Event": "push"}, ref: "refs/heads/feature/new-feature",
source: webhook.SourceGitHub, expected: "feature/new-feature",
expected: "push",
}, },
{ {
name: "extracts GitLab event type", name: "extracts develop branch",
headers: map[string]string{"X-Gitlab-Event": "Push Hook"}, ref: "refs/heads/develop",
source: webhook.SourceGitLab, expected: "develop",
expected: "Push Hook",
}, },
{ {
name: "returns push for unknown source", name: "returns raw ref if no prefix",
headers: map[string]string{}, ref: "main",
source: webhook.SourceUnknown, expected: "main",
expected: "push",
}, },
{ {
name: "returns push when header missing for source", name: "handles empty ref",
headers: map[string]string{}, ref: "",
source: webhook.SourceGitea, expected: "",
expected: "push", },
{
name: "handles partial prefix",
ref: "refs/heads/",
expected: "",
}, },
} }
@@ -218,318 +147,123 @@ func TestDetectEventType(testingT *testing.T) {
testingT.Run(testCase.name, func(t *testing.T) { testingT.Run(testCase.name, func(t *testing.T) {
t.Parallel() t.Parallel()
headers := http.Header{} // We test via HandleWebhook since extractBranch is not exported.
for key, value := range testCase.headers { // The test verifies behavior indirectly through the webhook event's branch.
headers.Set(key, value) svc, dbInst, cleanup := setupTestService(t)
} defer cleanup()
result := webhook.DetectEventType(headers, testCase.source) app := createTestApp(t, dbInst, testCase.expected)
assert.Equal(t, testCase.expected, result)
})
}
}
// TestWebhookSourceString tests the String method on WebhookSource. payload := []byte(`{"ref": "` + testCase.ref + `"}`)
func TestWebhookSourceString(t *testing.T) {
t.Parallel()
assert.Equal(t, "gitea", webhook.SourceGitea.String()) err := svc.HandleWebhook(context.Background(), app, "push", payload)
assert.Equal(t, "github", webhook.SourceGitHub.String())
assert.Equal(t, "gitlab", webhook.SourceGitLab.String())
assert.Equal(t, "unknown", webhook.SourceUnknown.String())
}
// TestUnparsedURLString tests the String method on UnparsedURL.
func TestUnparsedURLString(t *testing.T) {
t.Parallel()
u := webhook.UnparsedURL("https://example.com/test")
assert.Equal(t, "https://example.com/test", u.String())
empty := webhook.UnparsedURL("")
assert.Empty(t, empty.String())
}
// TestParsePushPayloadGitea tests parsing of Gitea push payloads.
func TestParsePushPayloadGitea(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456789",
"compare_url": "https://gitea.example.com/myorg/myrepo/compare/000...abc",
"repository": {
"full_name": "myorg/myrepo",
"clone_url": "https://gitea.example.com/myorg/myrepo.git",
"ssh_url": "git@gitea.example.com:myorg/myrepo.git",
"html_url": "https://gitea.example.com/myorg/myrepo"
},
"pusher": {"username": "developer", "email": "dev@example.com"},
"commits": [
{
"id": "abc123def456789",
"url": "https://gitea.example.com/myorg/myrepo/commit/abc123def456789",
"message": "Fix bug",
"author": {"name": "Developer", "email": "dev@example.com"}
}
]
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitea, payload)
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitea, event.Source)
assert.Equal(t, "refs/heads/main", event.Ref)
assert.Equal(t, "main", event.Branch)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "myorg/myrepo", event.RepoName)
assert.Equal(t, webhook.UnparsedURL("https://gitea.example.com/myorg/myrepo.git"), event.CloneURL)
assert.Equal(t, webhook.UnparsedURL("https://gitea.example.com/myorg/myrepo"), event.HTMLURL)
assert.Equal(t,
webhook.UnparsedURL("https://gitea.example.com/myorg/myrepo/commit/abc123def456789"),
event.CommitURL,
)
assert.Equal(t, "developer", event.Pusher)
}
// TestParsePushPayloadGitHub tests parsing of GitHub push payloads.
func TestParsePushPayloadGitHub(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456789",
"compare": "https://github.com/myorg/myrepo/compare/000...abc",
"repository": {
"full_name": "myorg/myrepo",
"clone_url": "https://github.com/myorg/myrepo.git",
"ssh_url": "git@github.com:myorg/myrepo.git",
"html_url": "https://github.com/myorg/myrepo"
},
"pusher": {"name": "developer", "email": "dev@example.com"},
"head_commit": {
"id": "abc123def456789",
"url": "https://github.com/myorg/myrepo/commit/abc123def456789",
"message": "Fix bug"
},
"commits": [
{
"id": "abc123def456789",
"url": "https://github.com/myorg/myrepo/commit/abc123def456789",
"message": "Fix bug",
"author": {"name": "Developer", "email": "dev@example.com"}
}
]
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitHub, event.Source)
assert.Equal(t, "refs/heads/main", event.Ref)
assert.Equal(t, "main", event.Branch)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "myorg/myrepo", event.RepoName)
assert.Equal(t, webhook.UnparsedURL("https://github.com/myorg/myrepo.git"), event.CloneURL)
assert.Equal(t, webhook.UnparsedURL("https://github.com/myorg/myrepo"), event.HTMLURL)
assert.Equal(t,
webhook.UnparsedURL("https://github.com/myorg/myrepo/commit/abc123def456789"),
event.CommitURL,
)
assert.Equal(t, "developer", event.Pusher)
}
// TestParsePushPayloadGitLab tests parsing of GitLab push payloads.
func TestParsePushPayloadGitLab(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/develop",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456789",
"user_name": "developer",
"user_email": "dev@example.com",
"project": {
"path_with_namespace": "mygroup/myproject",
"git_http_url": "https://gitlab.com/mygroup/myproject.git",
"git_ssh_url": "git@gitlab.com:mygroup/myproject.git",
"web_url": "https://gitlab.com/mygroup/myproject"
},
"commits": [
{
"id": "abc123def456789",
"url": "https://gitlab.com/mygroup/myproject/-/commit/abc123def456789",
"message": "Fix bug",
"author": {"name": "Developer", "email": "dev@example.com"}
}
]
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload)
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitLab, event.Source)
assert.Equal(t, "refs/heads/develop", event.Ref)
assert.Equal(t, "develop", event.Branch)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "mygroup/myproject", event.RepoName)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/mygroup/myproject.git"), event.CloneURL)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/mygroup/myproject"), event.HTMLURL)
assert.Equal(t,
webhook.UnparsedURL("https://gitlab.com/mygroup/myproject/-/commit/abc123def456789"),
event.CommitURL,
)
assert.Equal(t, "developer", event.Pusher)
}
// TestParsePushPayloadUnknownFallsBackToGitea tests that unknown source uses Gitea parser.
func TestParsePushPayloadUnknownFallsBackToGitea(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"after": "abc123",
"repository": {"full_name": "user/repo"},
"pusher": {"username": "user"}
}`)
event, err := webhook.ParsePushPayload(webhook.SourceUnknown, payload)
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitea, event.Source)
assert.Equal(t, "main", event.Branch)
assert.Equal(t, "abc123", event.After)
}
// TestParsePushPayloadInvalidJSON tests that invalid JSON returns an error.
func TestParsePushPayloadInvalidJSON(t *testing.T) {
t.Parallel()
sources := []webhook.Source{
webhook.SourceGitea,
webhook.SourceGitHub,
webhook.SourceGitLab,
}
for _, source := range sources {
t.Run(source.String(), func(t *testing.T) {
t.Parallel()
_, err := webhook.ParsePushPayload(source, []byte(`{invalid json}`))
require.Error(t, err)
})
}
}
// TestParsePushPayloadEmptyPayload tests parsing of empty JSON objects.
func TestParsePushPayloadEmptyPayload(t *testing.T) {
t.Parallel()
sources := []webhook.Source{
webhook.SourceGitea,
webhook.SourceGitHub,
webhook.SourceGitLab,
}
for _, source := range sources {
t.Run(source.String(), func(t *testing.T) {
t.Parallel()
event, err := webhook.ParsePushPayload(source, []byte(`{}`))
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, event.Branch) // Allow async deployment goroutine to complete before test cleanup
assert.Empty(t, event.After) time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, testCase.expected, events[0].Branch)
}) })
} }
} }
// TestGitHubCommitURLFallback tests commit URL extraction fallback paths for GitHub. func TestHandleWebhookMatchingBranch(t *testing.T) {
func TestGitHubCommitURLFallback(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("uses head_commit URL when available", func(t *testing.T) { svc, dbInst, cleanup := setupTestService(t)
t.Parallel() defer cleanup()
payload := []byte(`{ app := createTestApp(t, dbInst, "main")
"ref": "refs/heads/main",
"after": "abc123",
"head_commit": {"id": "abc123", "url": "https://github.com/u/r/commit/abc123"},
"repository": {"html_url": "https://github.com/u/r"}
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload) payload := []byte(`{
require.NoError(t, err) "ref": "refs/heads/main",
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL) "before": "0000000000000000000000000000000000000000",
}) "after": "abc123def456",
"repository": {
"full_name": "user/repo",
"clone_url": "https://gitea.example.com/user/repo.git",
"ssh_url": "git@gitea.example.com:user/repo.git"
},
"pusher": {"username": "testuser", "email": "test@example.com"},
"commits": [{"id": "abc123def456", "message": "Test commit",
"author": {"name": "Test User", "email": "test@example.com"}}]
}`)
t.Run("falls back to commits list", func(t *testing.T) { err := svc.HandleWebhook(context.Background(), app, "push", payload)
t.Parallel() require.NoError(t, err)
payload := []byte(`{ // Allow async deployment goroutine to complete before test cleanup
"ref": "refs/heads/main", time.Sleep(100 * time.Millisecond)
"after": "abc123",
"commits": [{"id": "abc123", "url": "https://github.com/u/r/commit/abc123"}],
"repository": {"html_url": "https://github.com/u/r"}
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload) events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL) require.Len(t, events, 1)
})
t.Run("constructs URL from repo HTML URL", func(t *testing.T) { event := events[0]
t.Parallel() assert.Equal(t, "push", event.EventType)
assert.Equal(t, "main", event.Branch)
payload := []byte(`{ assert.True(t, event.Matched)
"ref": "refs/heads/main", assert.Equal(t, "abc123def456", event.CommitSHA.String)
"after": "abc123",
"repository": {"html_url": "https://github.com/u/r"}
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL)
})
} }
// TestGitLabCommitURLFallback tests commit URL extraction fallback paths for GitLab. func TestHandleWebhookNonMatchingBranch(t *testing.T) {
func TestGitLabCommitURLFallback(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("uses commit URL from list", func(t *testing.T) { svc, dbInst, cleanup := setupTestService(t)
t.Parallel() defer cleanup()
payload := []byte(`{ app := createTestApp(t, dbInst, "main")
"ref": "refs/heads/main",
"after": "abc123",
"project": {"web_url": "https://gitlab.com/g/p"},
"commits": [{"id": "abc123", "url": "https://gitlab.com/g/p/-/commit/abc123"}]
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload) payload := []byte(`{"ref": "refs/heads/develop", "after": "def789ghi012"}`)
require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/g/p/-/commit/abc123"), event.CommitURL)
})
t.Run("constructs URL from project web URL", func(t *testing.T) { err := svc.HandleWebhook(context.Background(), app, "push", payload)
t.Parallel() require.NoError(t, err)
payload := []byte(`{ events, err := app.GetWebhookEvents(context.Background(), 10)
"ref": "refs/heads/main", require.NoError(t, err)
"after": "abc123", require.Len(t, events, 1)
"project": {"web_url": "https://gitlab.com/g/p"}
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload) assert.Equal(t, "develop", events[0].Branch)
require.NoError(t, err) assert.False(t, events[0].Matched)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/g/p/-/commit/abc123"), event.CommitURL) }
})
func TestHandleWebhookInvalidJSON(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(context.Background(), app, "push", []byte(`{invalid json}`))
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
}
func TestHandleWebhookEmptyPayload(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(context.Background(), app, "push", []byte(`{}`))
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.False(t, events[0].Matched)
} }
// TestGiteaPushPayloadParsing tests direct deserialization of the Gitea payload struct.
func TestGiteaPushPayloadParsing(testingT *testing.T) { func TestGiteaPushPayloadParsing(testingT *testing.T) {
testingT.Parallel() testingT.Parallel()
@@ -588,354 +322,6 @@ func TestGiteaPushPayloadParsing(testingT *testing.T) {
}) })
} }
// TestGitHubPushPayloadParsing tests direct deserialization of the GitHub payload struct.
func TestGitHubPushPayloadParsing(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000",
"after": "abc123",
"compare": "https://github.com/o/r/compare/000...abc",
"repository": {
"full_name": "o/r",
"clone_url": "https://github.com/o/r.git",
"ssh_url": "git@github.com:o/r.git",
"html_url": "https://github.com/o/r"
},
"pusher": {"name": "octocat", "email": "octocat@github.com"},
"head_commit": {
"id": "abc123",
"url": "https://github.com/o/r/commit/abc123",
"message": "Update README"
},
"commits": [
{
"id": "abc123",
"url": "https://github.com/o/r/commit/abc123",
"message": "Update README",
"author": {"name": "Octocat", "email": "octocat@github.com"}
}
]
}`)
var p webhook.GitHubPushPayload
err := json.Unmarshal(payload, &p)
require.NoError(t, err)
assert.Equal(t, "refs/heads/main", p.Ref)
assert.Equal(t, "abc123", p.After)
assert.Equal(t, "o/r", p.Repository.FullName)
assert.Equal(t, "octocat", p.Pusher.Name)
assert.NotNil(t, p.HeadCommit)
assert.Equal(t, "abc123", p.HeadCommit.ID)
assert.Len(t, p.Commits, 1)
}
// TestGitLabPushPayloadParsing tests direct deserialization of the GitLab payload struct.
func TestGitLabPushPayloadParsing(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000",
"after": "abc123",
"user_name": "gitlab-user",
"user_email": "user@gitlab.com",
"project": {
"path_with_namespace": "group/project",
"git_http_url": "https://gitlab.com/group/project.git",
"git_ssh_url": "git@gitlab.com:group/project.git",
"web_url": "https://gitlab.com/group/project"
},
"commits": [
{
"id": "abc123",
"url": "https://gitlab.com/group/project/-/commit/abc123",
"message": "Fix pipeline",
"author": {"name": "GitLab User", "email": "user@gitlab.com"}
}
]
}`)
var p webhook.GitLabPushPayload
err := json.Unmarshal(payload, &p)
require.NoError(t, err)
assert.Equal(t, "refs/heads/main", p.Ref)
assert.Equal(t, "abc123", p.After)
assert.Equal(t, "group/project", p.Project.PathWithNamespace)
assert.Equal(t, "gitlab-user", p.UserName)
assert.Len(t, p.Commits, 1)
}
// TestExtractBranch tests branch extraction via HandleWebhook integration (extractBranch is unexported).
//
//nolint:funlen // table-driven test with comprehensive test cases
func TestExtractBranch(testingT *testing.T) {
testingT.Parallel()
tests := []struct {
name string
ref string
expected string
}{
{
name: "extracts main branch",
ref: "refs/heads/main",
expected: "main",
},
{
name: "extracts feature branch",
ref: "refs/heads/feature/new-feature",
expected: "feature/new-feature",
},
{
name: "extracts develop branch",
ref: "refs/heads/develop",
expected: "develop",
},
{
name: "returns raw ref if no prefix",
ref: "main",
expected: "main",
},
{
name: "handles empty ref",
ref: "",
expected: "",
},
{
name: "handles partial prefix",
ref: "refs/heads/",
expected: "",
},
}
for _, testCase := range tests {
testingT.Run(testCase.name, func(t *testing.T) {
t.Parallel()
// We test via HandleWebhook since extractBranch is not exported.
// The test verifies behavior indirectly through the webhook event's branch.
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, testCase.expected)
payload := []byte(`{"ref": "` + testCase.ref + `"}`)
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, "push", payload,
)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, testCase.expected, events[0].Branch)
})
}
}
func TestHandleWebhookMatchingBranch(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456",
"repository": {
"full_name": "user/repo",
"clone_url": "https://gitea.example.com/user/repo.git",
"ssh_url": "git@gitea.example.com:user/repo.git"
},
"pusher": {"username": "testuser", "email": "test@example.com"},
"commits": [{"id": "abc123def456", "message": "Test commit",
"author": {"name": "Test User", "email": "test@example.com"}}]
}`)
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, "push", payload,
)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, "push", event.EventType)
assert.Equal(t, "main", event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, "abc123def456", event.CommitSHA.String)
}
func TestHandleWebhookNonMatchingBranch(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{"ref": "refs/heads/develop", "after": "def789ghi012"}`)
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, "push", payload,
)
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, "develop", events[0].Branch)
assert.False(t, events[0].Matched)
}
func TestHandleWebhookInvalidJSON(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, "push", []byte(`{invalid json}`),
)
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
}
func TestHandleWebhookEmptyPayload(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, "push", []byte(`{}`),
)
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.False(t, events[0].Matched)
}
// TestHandleWebhookGitHubSource tests HandleWebhook with a GitHub push payload.
func TestHandleWebhookGitHubSource(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{
"ref": "refs/heads/main",
"after": "github123",
"repository": {
"full_name": "org/repo",
"clone_url": "https://github.com/org/repo.git",
"html_url": "https://github.com/org/repo"
},
"pusher": {"name": "octocat", "email": "octocat@github.com"},
"head_commit": {
"id": "github123",
"url": "https://github.com/org/repo/commit/github123",
"message": "Update feature"
}
}`)
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitHub, "push", payload,
)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, "main", event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, "github123", event.CommitSHA.String)
assert.Equal(t, "https://github.com/org/repo/commit/github123", event.CommitURL.String)
}
// TestHandleWebhookGitLabSource tests HandleWebhook with a GitLab push payload.
func TestHandleWebhookGitLabSource(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{
"ref": "refs/heads/main",
"after": "gitlab456",
"user_name": "gitlab-dev",
"user_email": "dev@gitlab.com",
"project": {
"path_with_namespace": "group/project",
"git_http_url": "https://gitlab.com/group/project.git",
"web_url": "https://gitlab.com/group/project"
},
"commits": [
{
"id": "gitlab456",
"url": "https://gitlab.com/group/project/-/commit/gitlab456",
"message": "Deploy fix"
}
]
}`)
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitLab, "push", payload,
)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, "main", event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, "gitlab456", event.CommitSHA.String)
assert.Equal(t, "https://gitlab.com/group/project/-/commit/gitlab456", event.CommitURL.String)
}
// TestSetupTestService verifies the test helper creates a working test service. // TestSetupTestService verifies the test helper creates a working test service.
func TestSetupTestService(testingT *testing.T) { func TestSetupTestService(testingT *testing.T) {
testingT.Parallel() testingT.Parallel()
@@ -955,25 +341,3 @@ func TestSetupTestService(testingT *testing.T) {
require.NoError(t, err) require.NoError(t, err)
}) })
} }
// TestPushEventConstruction tests that PushEvent can be constructed directly.
func TestPushEventConstruction(t *testing.T) {
t.Parallel()
event := webhook.PushEvent{
Source: webhook.SourceGitHub,
Ref: "refs/heads/main",
Before: "000",
After: "abc",
Branch: "main",
RepoName: "org/repo",
CloneURL: webhook.UnparsedURL("https://github.com/org/repo.git"),
HTMLURL: webhook.UnparsedURL("https://github.com/org/repo"),
CommitURL: webhook.UnparsedURL("https://github.com/org/repo/commit/abc"),
Pusher: "user",
}
assert.Equal(t, "main", event.Branch)
assert.Equal(t, webhook.SourceGitHub, event.Source)
assert.Equal(t, "abc", event.After)
}

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