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 -} -``` diff --git a/internal/handlers/app.go b/internal/handlers/app.go index 1650543..72fb07c 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" @@ -1140,6 +1142,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 77b4c66..21e4d3d 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -83,14 +83,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 f329ca0..48234ac 100644 --- a/templates/app_detail.html +++ b/templates/app_detail.html @@ -112,15 +112,34 @@
{{range .EnvVars}} -⚠ Container restart needed after env var changes.
+