From e9d284698a9cd91bb15f09ebe364b0e03bb66d2a Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 16 Feb 2026 00:26:07 -0800 Subject: [PATCH 1/2] feat: edit existing env vars, labels, and volume mounts Add inline edit functionality for environment variables, labels, and volume mounts on the app detail page. Each entity row now has an Edit button that reveals an inline form using Alpine.js. - POST /apps/{id}/env-vars/{varID}/edit - POST /apps/{id}/labels/{labelID}/edit - POST /apps/{id}/volumes/{volumeID}/edit - Path validation for volume host and container paths - Warning banner about container restart after env var changes - Tests for ValidateVolumePath fixes #67 --- internal/handlers/app.go | 203 ++++++++++++++++++++ internal/handlers/volume_validation_test.go | 34 ++++ internal/server/routes.go | 3 + templates/app_detail.html | 129 +++++++++---- 4 files changed, 335 insertions(+), 34 deletions(-) create mode 100644 internal/handlers/volume_validation_test.go diff --git a/internal/handlers/app.go b/internal/handlers/app.go index 492fa9a..5c58da4 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -4,6 +4,8 @@ import ( "context" "database/sql" "encoding/json" + "errors" + "fmt" "net/http" "os" "path/filepath" @@ -1116,6 +1118,207 @@ func (h *Handlers) HandlePortDelete() http.HandlerFunc { } } +// ErrVolumePathEmpty is returned when a volume path is empty. +var ErrVolumePathEmpty = errors.New("path must not be empty") + +// ErrVolumePathNotAbsolute is returned when a volume path is not absolute. +var ErrVolumePathNotAbsolute = errors.New("path must be absolute") + +// ErrVolumePathNotClean is returned when a volume path is not clean. +var ErrVolumePathNotClean = errors.New("path must be clean") + +// ValidateVolumePath checks that a path is absolute and clean. +func ValidateVolumePath(p string) error { + if p == "" { + return ErrVolumePathEmpty + } + + if !filepath.IsAbs(p) { + return ErrVolumePathNotAbsolute + } + + cleaned := filepath.Clean(p) + if cleaned != p { + return fmt.Errorf("%w (expected %q)", ErrVolumePathNotClean, cleaned) + } + + 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) { + appID := chi.URLParam(request, "id") + labelIDStr := chi.URLParam(request, "labelID") + + labelID, parseErr := strconv.ParseInt(labelIDStr, 10, 64) + if parseErr != nil { + http.NotFound(writer, request) + + return + } + + label, findErr := models.FindLabel(request.Context(), h.db, labelID) + if findErr != nil || label == nil || label.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 + } + + label.Key = key + label.Value = value + + saveErr := label.Save(request.Context()) + if saveErr != nil { + h.log.Error("failed to update label", "error", saveErr) + } + + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + } +} + +// HandleVolumeEdit handles editing an existing volume mount. +func (h *Handlers) HandleVolumeEdit() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + appID := chi.URLParam(request, "id") + volumeIDStr := chi.URLParam(request, "volumeID") + + volumeID, parseErr := strconv.ParseInt(volumeIDStr, 10, 64) + if parseErr != nil { + http.NotFound(writer, request) + + return + } + + volume, findErr := models.FindVolume(request.Context(), h.db, volumeID) + if findErr != nil || volume == nil || volume.AppID != appID { + http.NotFound(writer, request) + + return + } + + formErr := request.ParseForm() + if formErr != nil { + http.Error(writer, "Bad Request", http.StatusBadRequest) + + return + } + + hostPath := request.FormValue("host_path") + containerPath := request.FormValue("container_path") + readOnly := request.FormValue("readonly") == "1" + + if hostPath == "" || containerPath == "" { + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + + return + } + + pathErr := validateVolumePaths(hostPath, containerPath) + if pathErr != nil { + h.log.Error("invalid volume path", "error", pathErr) + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + + return + } + + volume.HostPath = hostPath + volume.ContainerPath = containerPath + volume.ReadOnly = readOnly + + saveErr := volume.Save(request.Context()) + if saveErr != nil { + h.log.Error("failed to update volume", "error", saveErr) + } + + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + } +} + +// validateVolumePaths validates both host and container paths for a volume. +func validateVolumePaths(hostPath, containerPath string) error { + hostErr := ValidateVolumePath(hostPath) + if hostErr != nil { + return fmt.Errorf("host path: %w", hostErr) + } + + containerErr := ValidateVolumePath(containerPath) + if containerErr != nil { + return fmt.Errorf("container path: %w", containerErr) + } + + return 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 { diff --git a/internal/handlers/volume_validation_test.go b/internal/handlers/volume_validation_test.go new file mode 100644 index 0000000..9389fe3 --- /dev/null +++ b/internal/handlers/volume_validation_test.go @@ -0,0 +1,34 @@ +package handlers //nolint:testpackage // tests exported ValidateVolumePath function + +import "testing" + +func TestValidateVolumePath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantErr bool + }{ + {"valid absolute path", "/data/myapp", false}, + {"root path", "/", false}, + {"empty path", "", true}, + {"relative path", "data/myapp", true}, + {"path with dotdot", "/data/../etc", true}, + {"path with trailing slash", "/data/", true}, + {"path with double slash", "/data//myapp", true}, + {"single dot path", ".", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateVolumePath(tt.path) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateVolumePath(%q) error = %v, wantErr %v", + tt.path, err, tt.wantErr) + } + }) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 3acadea..fc4cc7f 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -82,14 +82,17 @@ func (s *Server) SetupRoutes() { // 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()) + r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit()) r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete()) // Volumes r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd()) + r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit()) r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete()) // Ports diff --git a/templates/app_detail.html b/templates/app_detail.html index 14fb91e..ce2b812 100644 --- a/templates/app_detail.html +++ b/templates/app_detail.html @@ -106,15 +106,34 @@ {{range .EnvVars}} - - {{.Key}} - {{.Value}} - -
- {{ .CSRFField }} - -
- + + + + + {{end}} @@ -151,15 +170,33 @@ {{range .Labels}} - - {{.Key}} - {{.Value}} - -
- {{ .CSRFField }} - -
- + + + + + {{end}} @@ -189,22 +226,46 @@ {{range .Volumes}} - - {{.HostPath}} - {{.ContainerPath}} - - {{if .ReadOnly}} - Read-only - {{else}} - Read-write - {{end}} - - -
- {{ .CSRFField }} - -
- + + + + + + {{end}} From e09cf11c060fa00bfba763127399dfeaeac72b3b Mon Sep 17 00:00:00 2001 From: user Date: Mon, 16 Feb 2026 00:35:23 -0800 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20remove=20TODO.md=20=E2=80=94=20all?= =?UTF-8?q?=20items=20tracked=20as=20Gitea=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All unchecked items now have corresponding issues: - #67 Edit env vars/labels/volumes (merged) - #68 GitHub/GitLab webhook support - #69 JSON API (PR #74 open) - #72 CPU/memory resource limits - #79 Backup/restore - #80 Private Docker registry auth - #81 Custom health checks - #82 Multi-user support with roles - #83 Scheduled deployments - #84 Observability (logging, metrics, audit) - #85 Webhook event history UI - #86 Settings page Completed items: #66 (cancel endpoint), #67 (edit entities), #71 (rollback), plus all Phase 1-2 items already done. --- TODO.md | 312 -------------------------------------------------------- 1 file changed, 312 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 586b05d..0000000 --- a/TODO.md +++ /dev/null @@ -1,312 +0,0 @@ -# UPAAS Implementation Plan - -## Feature Roadmap - -### Core Infrastructure -- [x] Uber fx dependency injection -- [x] Chi router integration -- [x] Structured logging (slog) with TTY detection -- [x] Configuration via Viper (env vars, config files) -- [x] SQLite database with embedded migrations -- [x] Embedded templates (html/template) -- [x] Embedded static assets (Tailwind CSS, JS) -- [x] Server startup (`Server.Run()`) -- [x] Graceful shutdown (`Server.Shutdown()`) -- [x] Route wiring (`SetupRoutes()`) - -### Authentication & Authorization -- [x] Single admin user model -- [x] Argon2id password hashing -- [x] Initial setup flow (create admin on first run) -- [x] Cookie-based session management (gorilla/sessions) -- [x] Session middleware for protected routes -- [x] Login/logout handlers -- [ ] API token authentication (for JSON API) - -### App Management -- [x] Create apps with name, repo URL, branch, Dockerfile path -- [x] Edit app configuration -- [x] Delete apps (cascades to related entities) -- [x] List all apps on dashboard -- [x] View app details -- [x] Per-app SSH keypair generation (Ed25519) -- [x] Per-app webhook secret (UUID) - -### Container Configuration -- [x] Environment variables (add, delete per app) -- [x] Docker labels (add, delete per app) -- [x] Volume mounts (add, delete per app, with read-only option) -- [x] Docker network configuration per app -- [ ] Edit existing environment variables -- [ ] Edit existing labels -- [ ] Edit existing volume mounts -- [ ] CPU/memory resource limits - -### Deployment Pipeline -- [x] Manual deploy trigger from UI -- [x] Repository cloning via Docker git container -- [x] SSH key authentication for private repos -- [x] Docker image building with configurable Dockerfile -- [x] Container creation with env vars, labels, volumes -- [x] Old container removal before new deployment -- [x] Deployment status tracking (building, deploying, success, failed) -- [x] Deployment logs storage -- [x] View deployment history per app -- [x] Container logs viewing -- [ ] Deployment rollback to previous image -- [x] Deployment cancellation - -### Manual Container Controls -- [x] Restart container -- [x] Stop container -- [x] Start stopped container - -### Webhook Integration -- [x] Gitea webhook endpoint (`/webhook/:secret`) -- [x] Push event parsing -- [x] Branch extraction from refs -- [x] Branch matching (only deploy configured branch) -- [x] Webhook event audit log -- [x] Automatic deployment on matching webhook -- [ ] Webhook event history UI -- [ ] GitHub webhook support -- [ ] GitLab webhook support - -### Health Monitoring -- [x] Health check endpoint (`/health`) -- [x] Application uptime tracking -- [x] Docker container health status checking -- [x] Post-deployment health verification (60s delay) -- [ ] Custom health check commands per app - -### Notifications -- [x] ntfy integration (HTTP POST) -- [x] Slack-compatible webhook integration -- [x] Build start/success/failure notifications -- [x] Deploy success/failure notifications -- [x] Priority mapping for notification urgency - -### Observability -- [x] Request logging middleware -- [x] Request ID generation -- [x] Sentry error reporting (optional) -- [x] Prometheus metrics endpoint (optional, with basic auth) -- [ ] Structured logging for all operations -- [ ] Deployment count/duration metrics -- [ ] Container health status metrics -- [ ] Webhook event metrics -- [ ] Audit log table for user actions - -### API -- [ ] JSON API (`/api/v1/*`) -- [ ] List apps endpoint -- [ ] Get app details endpoint -- [ ] Create app endpoint -- [ ] Delete app endpoint -- [ ] Trigger deploy endpoint -- [ ] List deployments endpoint -- [ ] API documentation - -### UI Features -- [x] Server-rendered HTML templates -- [x] Dashboard with app list -- [x] App creation form -- [x] App detail view with all configurations -- [x] App edit form -- [x] Deployment history page -- [x] Login page -- [x] Setup page -- [ ] Container logs page -- [ ] Webhook event history page -- [ ] Settings page (webhook secret, SSH public key) -- [ ] Real-time deployment log streaming (WebSocket/SSE) - -### Future Considerations -- [ ] Multi-user support with roles -- [ ] Private Docker registry authentication -- [ ] Scheduled deployments -- [ ] Backup/restore of app configurations - ---- - -## Phase 1: Critical (Application Cannot Start) - -### 1.1 Server Startup Infrastructure -- [x] Implement `Server.Run()` in `internal/server/server.go` - - Start HTTP server with configured address/port - - Handle TLS if configured - - Block until shutdown signal received -- [x] Implement `Server.Shutdown()` in `internal/server/server.go` - - Graceful shutdown with context timeout - - Close database connections - - Stop running containers gracefully (optional) -- [x] Implement `SetupRoutes()` in `internal/server/routes.go` - - Wire up chi router with all handlers - - Apply middleware (logging, auth, CORS, metrics) - - Define public vs protected route groups - - Serve static assets and templates - -### 1.2 Route Configuration -``` -Public Routes: - GET /health - GET /setup, POST /setup - GET /login, POST /login - POST /webhook/:secret - -Protected Routes (require auth): - GET /logout - GET /dashboard - GET /apps/new, POST /apps - GET /apps/:id, POST /apps/:id, DELETE /apps/:id - GET /apps/:id/edit, POST /apps/:id/edit - GET /apps/:id/deployments - GET /apps/:id/logs - POST /apps/:id/env-vars, DELETE /apps/:id/env-vars/:id - POST /apps/:id/labels, DELETE /apps/:id/labels/:id - POST /apps/:id/volumes, DELETE /apps/:id/volumes/:id - POST /apps/:id/deploy -``` - -## Phase 2: High Priority (Core Functionality Gaps) - -### 2.1 Container Logs -- [x] Implement `HandleAppLogs()` in `internal/handlers/app.go` - - Fetch logs via Docker API (`ContainerLogs`) - - Support tail parameter (last N lines) - - Stream logs with SSE or chunked response -- [x] Add Docker client method `GetContainerLogs(containerID, tail int) (io.Reader, error)` - -### 2.2 Manual Container Controls -- [x] Add `POST /apps/:id/restart` endpoint - - Stop and start container - - Record restart in deployment log -- [x] Add `POST /apps/:id/stop` endpoint - - Stop container without deleting - - Update app status -- [x] Add `POST /apps/:id/start` endpoint - - Start stopped container - - Run health check - -## Phase 3: Medium Priority (UX Improvements) - -### 3.1 Edit Operations for Related Entities -- [ ] Add `PUT /apps/:id/env-vars/:id` endpoint - - Update existing environment variable value - - Trigger container restart with new env -- [ ] Add `PUT /apps/:id/labels/:id` endpoint - - Update existing Docker label -- [ ] Add `PUT /apps/:id/volumes/:id` endpoint - - Update volume mount paths - - Validate paths before saving - -### 3.2 Deployment Rollback -- [ ] Add `previous_image_id` column to apps table - - Store last successful image ID before new deploy -- [ ] Add `POST /apps/:id/rollback` endpoint - - Stop current container - - Start container with previous image - - Create deployment record for rollback -- [ ] Update deploy service to save previous image before building new one - -### 3.3 Deployment Cancellation -- [x] Add cancellation context to deploy service -- [x] Add `POST /apps/:id/deployments/:id/cancel` endpoint -- [x] Handle cleanup of partial builds/containers - -## Phase 4: Lower Priority (Nice to Have) - -### 4.1 JSON API -- [ ] Add `/api/v1` route group with JSON responses -- [ ] Implement API endpoints mirroring web routes: - - `GET /api/v1/apps` - list apps - - `POST /api/v1/apps` - create app - - `GET /api/v1/apps/:id` - get app details - - `DELETE /api/v1/apps/:id` - delete app - - `POST /api/v1/apps/:id/deploy` - trigger deploy - - `GET /api/v1/apps/:id/deployments` - list deployments -- [ ] Add API token authentication (separate from session auth) -- [ ] Document API in README - -### 4.2 Resource Limits -- [ ] Add `cpu_limit` and `memory_limit` columns to apps table -- [ ] Add fields to app edit form -- [ ] Pass limits to Docker container create - -### 4.3 UI Improvements -- [ ] Add webhook event history page - - Show received webhooks per app - - Display match/no-match status -- [ ] Add settings page - - View/regenerate webhook secret - - View SSH public key -- [ ] Add real-time deployment log streaming - - WebSocket or SSE for live build output - -### 4.4 Observability -- [ ] Add structured logging for all operations -- [ ] Add Prometheus metrics for: - - Deployment count/duration - - Container health status - - Webhook events received -- [ ] Add audit log table for user actions - -## Phase 5: Future Considerations - -- [ ] Multi-user support with roles -- [ ] Private Docker registry authentication -- [ ] Custom health check commands per app -- [ ] Scheduled deployments -- [ ] Backup/restore of app configurations -- [ ] GitHub/GitLab webhook support (in addition to Gitea) - ---- - -## Implementation Notes - -### Server.Run() Example -```go -func (s *Server) Run() error { - s.SetupRoutes() - - srv := &http.Server{ - Addr: s.config.ListenAddr, - Handler: s.router, - } - - go func() { - <-s.shutdownCh - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - srv.Shutdown(ctx) - }() - - return srv.ListenAndServe() -} -``` - -### SetupRoutes() Structure -```go -func (s *Server) SetupRoutes() { - r := chi.NewRouter() - - // Global middleware - r.Use(s.middleware.RequestID) - r.Use(s.middleware.Logger) - r.Use(s.middleware.Recoverer) - - // Public routes - r.Get("/health", s.handlers.HandleHealthCheck()) - r.Get("/login", s.handlers.HandleLoginPage()) - // ... - - // Protected routes - r.Group(func(r chi.Router) { - r.Use(s.middleware.SessionAuth) - r.Get("/dashboard", s.handlers.HandleDashboard()) - // ... - }) - - s.router = r -} -```