Compare commits

..

1 Commits

Author SHA1 Message Date
user
8156305705 feat: add edit support for env vars, labels, and volumes
- Add POST /apps/{id}/env-vars/{varID}/edit endpoint
- Add POST /apps/{id}/labels/{labelID}/edit endpoint
- Add POST /apps/{id}/volumes/{volumeID}/edit endpoint
- Add inline edit UI with Alpine.js toggle in app_detail template
- Models already support Save() with update when ID != 0

Closes #67
2026-02-16 00:25:34 -08:00
13 changed files with 520 additions and 1180 deletions

312
TODO.md Normal file
View File

@ -0,0 +1,312 @@
# 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
- [ ] 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
- [ ] Add cancellation context to deploy service
- [ ] Add `POST /apps/:id/deployments/:id/cancel` endpoint
- [ ] 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
}
```

View File

@ -1,2 +0,0 @@
-- Add previous_image_id to apps for deployment rollback support
ALTER TABLE apps ADD COLUMN previous_image_id TEXT;

View File

@ -1,377 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/app"
)
// apiAppResponse is the JSON representation of an app.
type apiAppResponse struct {
ID string `json:"id"`
Name string `json:"name"`
RepoURL string `json:"repoUrl"`
Branch string `json:"branch"`
DockerfilePath string `json:"dockerfilePath"`
Status string `json:"status"`
WebhookSecret string `json:"webhookSecret"`
SSHPublicKey string `json:"sshPublicKey"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// apiDeploymentResponse is the JSON representation of a deployment.
type apiDeploymentResponse struct {
ID int64 `json:"id"`
AppID string `json:"appId"`
CommitSHA string `json:"commitSha,omitempty"`
Status string `json:"status"`
Duration string `json:"duration,omitempty"`
StartedAt string `json:"startedAt"`
FinishedAt string `json:"finishedAt,omitempty"`
}
func appToAPI(a *models.App) apiAppResponse {
return apiAppResponse{
ID: a.ID,
Name: a.Name,
RepoURL: a.RepoURL,
Branch: a.Branch,
DockerfilePath: a.DockerfilePath,
Status: string(a.Status),
WebhookSecret: a.WebhookSecret,
SSHPublicKey: a.SSHPublicKey,
CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: a.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
resp := apiDeploymentResponse{
ID: d.ID,
AppID: d.AppID,
Status: string(d.Status),
Duration: d.Duration(),
StartedAt: d.StartedAt.Format("2006-01-02T15:04:05Z"),
}
if d.CommitSHA.Valid {
resp.CommitSHA = d.CommitSHA.String
}
if d.FinishedAt.Valid {
resp.FinishedAt = d.FinishedAt.Time.Format("2006-01-02T15:04:05Z")
}
return resp
}
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
// and sets a session cookie.
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type loginResponse struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
}
return func(writer http.ResponseWriter, request *http.Request) {
var req loginRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid JSON body"},
http.StatusBadRequest)
return
}
if req.Username == "" || req.Password == "" {
h.respondJSON(writer, request,
map[string]string{"error": "username and password are required"},
http.StatusBadRequest)
return
}
user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password)
if authErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid credentials"},
http.StatusUnauthorized)
return
}
sessionErr := h.auth.CreateSession(writer, request, user)
if sessionErr != nil {
h.log.Error("api: failed to create session", "error", sessionErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to create session"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, loginResponse{
UserID: user.ID,
Username: user.Username,
}, http.StatusOK)
}
}
// HandleAPIListApps returns a handler that lists all apps as JSON.
func (h *Handlers) HandleAPIListApps() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
apps, err := h.appService.ListApps(request.Context())
if err != nil {
h.respondJSON(writer, request,
map[string]string{"error": "failed to list apps"},
http.StatusInternalServerError)
return
}
result := make([]apiAppResponse, 0, len(apps))
for _, a := range apps {
result = append(result, appToAPI(a))
}
h.respondJSON(writer, request, result, http.StatusOK)
}
}
// HandleAPIGetApp returns a handler that gets a single app by ID.
func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil {
h.respondJSON(writer, request,
map[string]string{"error": "internal server error"},
http.StatusInternalServerError)
return
}
if application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
h.respondJSON(writer, request, appToAPI(application), http.StatusOK)
}
}
// HandleAPICreateApp returns a handler that creates a new app.
func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
type createRequest struct {
Name string `json:"name"`
RepoURL string `json:"repoUrl"`
Branch string `json:"branch"`
DockerfilePath string `json:"dockerfilePath"`
DockerNetwork string `json:"dockerNetwork"`
NtfyTopic string `json:"ntfyTopic"`
SlackWebhook string `json:"slackWebhook"`
}
return func(writer http.ResponseWriter, request *http.Request) {
var req createRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid JSON body"},
http.StatusBadRequest)
return
}
if req.Name == "" || req.RepoURL == "" {
h.respondJSON(writer, request,
map[string]string{"error": "name and repo_url are required"},
http.StatusBadRequest)
return
}
nameErr := validateAppName(req.Name)
if nameErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid app name: " + nameErr.Error()},
http.StatusBadRequest)
return
}
createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{
Name: req.Name,
RepoURL: req.RepoURL,
Branch: req.Branch,
DockerfilePath: req.DockerfilePath,
DockerNetwork: req.DockerNetwork,
NtfyTopic: req.NtfyTopic,
SlackWebhook: req.SlackWebhook,
})
if createErr != nil {
h.log.Error("api: failed to create app", "error", createErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to create app"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, appToAPI(createdApp), http.StatusCreated)
}
}
// HandleAPIDeleteApp returns a handler that deletes an app.
func (h *Handlers) HandleAPIDeleteApp() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil {
h.respondJSON(writer, request,
map[string]string{"error": "internal server error"},
http.StatusInternalServerError)
return
}
if application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
deleteErr := h.appService.DeleteApp(request.Context(), application)
if deleteErr != nil {
h.log.Error("api: failed to delete app", "error", deleteErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to delete app"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request,
map[string]string{"status": "deleted"}, http.StatusOK)
}
}
// deploymentsPageLimit is the default number of deployments per page.
const deploymentsPageLimit = 20
// HandleAPIListDeployments returns a handler that lists deployments for an app.
func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil || application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
limit := deploymentsPageLimit
if l := request.URL.Query().Get("limit"); l != "" {
parsed, parseErr := strconv.Atoi(l)
if parseErr == nil && parsed > 0 {
limit = parsed
}
}
deployments, deployErr := application.GetDeployments(
request.Context(), limit,
)
if deployErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "failed to list deployments"},
http.StatusInternalServerError)
return
}
result := make([]apiDeploymentResponse, 0, len(deployments))
for _, d := range deployments {
result = append(result, deploymentToAPI(d))
}
h.respondJSON(writer, request, result, http.StatusOK)
}
}
// HandleAPITriggerDeploy returns a handler that triggers a deployment for an app.
func (h *Handlers) HandleAPITriggerDeploy() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil || application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
deployErr := h.deploy.Deploy(request.Context(), application, nil, true)
if deployErr != nil {
h.log.Error("api: failed to trigger deploy", "error", deployErr)
h.respondJSON(writer, request,
map[string]string{"error": deployErr.Error()},
http.StatusConflict)
return
}
h.respondJSON(writer, request,
map[string]string{"status": "deploying"}, http.StatusAccepted)
}
}
// HandleAPIWhoAmI returns a handler that shows the current authenticated user.
func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
type whoAmIResponse struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
}
return func(writer http.ResponseWriter, request *http.Request) {
user, err := h.auth.GetCurrentUser(request.Context(), request)
if err != nil || user == nil {
h.respondJSON(writer, request,
map[string]string{"error": "unauthorized"},
http.StatusUnauthorized)
return
}
h.respondJSON(writer, request, whoAmIResponse{
UserID: user.ID,
Username: user.Username,
}, http.StatusOK)
}
}

View File

@ -1,299 +0,0 @@
package handlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// apiRouter builds a chi router with the API routes using session auth middleware.
func apiRouter(tc *testContext) http.Handler {
r := chi.NewRouter()
r.Route("/api/v1", func(apiR chi.Router) {
apiR.Post("/login", tc.handlers.HandleAPILoginPOST())
apiR.Group(func(apiR chi.Router) {
apiR.Use(tc.middleware.APISessionAuth())
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
apiR.Get("/apps", tc.handlers.HandleAPIListApps())
apiR.Post("/apps", tc.handlers.HandleAPICreateApp())
apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp())
apiR.Delete("/apps/{id}", tc.handlers.HandleAPIDeleteApp())
apiR.Post("/apps/{id}/deploy", tc.handlers.HandleAPITriggerDeploy())
apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments())
})
})
return r
}
// setupAPITest creates a test context with a user and returns session cookies.
func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
t.Helper()
tc := setupTestHandlers(t)
// Create a user.
_, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123")
require.NoError(t, err)
// Login via the API to get session cookies.
r := apiRouter(tc)
loginBody := `{"username":"admin","password":"password123"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
cookies := rr.Result().Cookies()
require.NotEmpty(t, cookies, "login should return session cookies")
return tc, cookies
}
// apiRequest makes an authenticated API request using session cookies.
func apiRequest(
t *testing.T,
tc *testContext,
cookies []*http.Cookie,
method, path string,
body string,
) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if body != "" {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r := apiRouter(tc)
r.ServeHTTP(rr, req)
return rr
}
func TestAPILoginSuccess(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
_, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123")
require.NoError(t, err)
r := apiRouter(tc)
body := `{"username":"admin","password":"password123"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
assert.Equal(t, "admin", resp["username"])
// Should have a Set-Cookie header.
assert.NotEmpty(t, rr.Result().Cookies())
}
func TestAPILoginInvalidCredentials(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
_, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123")
require.NoError(t, err)
r := apiRouter(tc)
body := `{"username":"admin","password":"wrong"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestAPILoginMissingFields(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
r := apiRouter(tc)
body := `{"username":"","password":""}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestAPIRejectsUnauthenticated(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
r := apiRouter(tc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestAPIWhoAmI(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/whoami", "")
assert.Equal(t, http.StatusOK, rr.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
assert.Equal(t, "admin", resp["username"])
}
func TestAPIListAppsEmpty(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps", "")
assert.Equal(t, http.StatusOK, rr.Code)
var apps []any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &apps))
assert.Empty(t, apps)
}
func TestAPICreateApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"test-app","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
assert.Equal(t, http.StatusCreated, rr.Code)
var app map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
assert.Equal(t, "test-app", app["name"])
assert.Equal(t, "pending", app["status"])
}
func TestAPICreateAppValidation(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"","repoUrl":""}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestAPIGetApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"my-app","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusOK, rr.Code)
var app map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
assert.Equal(t, "my-app", app["name"])
}
func TestAPIGetAppNotFound(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/nonexistent", "")
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestAPIDeleteApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"delete-me","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodDelete, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusOK, rr.Code)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestAPIListDeployments(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"deploy-app","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID+"/deployments", "")
assert.Equal(t, http.StatusOK, rr.Code)
var deployments []any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &deployments))
assert.Empty(t, deployments)
}

View File

@ -4,8 +4,6 @@ import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -382,30 +380,6 @@ func (h *Handlers) HandleCancelDeploy() http.HandlerFunc {
} }
} }
// HandleAppRollback handles rolling back to the previous deployment image.
func (h *Handlers) HandleAppRollback() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
rollbackErr := h.deploy.Rollback(request.Context(), application)
if rollbackErr != nil {
h.log.Error("rollback failed", "error", rollbackErr, "app", application.Name)
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
http.Redirect(writer, request, "/apps/"+application.ID+"?success=rolledback", http.StatusSeeOther)
}
}
// HandleAppDeployments returns the deployments history handler. // HandleAppDeployments returns the deployments history handler.
func (h *Handlers) HandleAppDeployments() http.HandlerFunc { func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
@ -922,6 +896,54 @@ func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc {
} }
} }
// 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 edit env var", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// HandleLabelAdd handles adding a label. // HandleLabelAdd handles adding a label.
func (h *Handlers) HandleLabelAdd() http.HandlerFunc { func (h *Handlers) HandleLabelAdd() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@ -969,6 +991,54 @@ func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
} }
} }
// 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 edit label", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// HandleVolumeAdd handles adding a volume mount. // HandleVolumeAdd handles adding a volume mount.
func (h *Handlers) HandleVolumeAdd() http.HandlerFunc { func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@ -1047,6 +1117,56 @@ func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
} }
} }
// 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
}
volume.HostPath = hostPath
volume.ContainerPath = containerPath
volume.ReadOnly = readOnly
saveErr := volume.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to edit volume", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// HandlePortAdd handles adding a port mapping. // HandlePortAdd handles adding a port mapping.
func (h *Handlers) HandlePortAdd() http.HandlerFunc { func (h *Handlers) HandlePortAdd() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@ -1142,207 +1262,6 @@ 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. // formatDeployKey formats an SSH public key with a descriptive comment.
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp // Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string { func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {

View File

@ -24,7 +24,6 @@ import (
"git.eeqj.de/sneak/upaas/internal/handlers" "git.eeqj.de/sneak/upaas/internal/handlers"
"git.eeqj.de/sneak/upaas/internal/healthcheck" "git.eeqj.de/sneak/upaas/internal/healthcheck"
"git.eeqj.de/sneak/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/middleware"
"git.eeqj.de/sneak/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
@ -33,11 +32,10 @@ import (
) )
type testContext struct { type testContext struct {
handlers *handlers.Handlers handlers *handlers.Handlers
database *database.Database database *database.Database
authSvc *auth.Service authSvc *auth.Service
appSvc *app.Service appSvc *app.Service
middleware *middleware.Middleware
} }
func createTestConfig(t *testing.T) *config.Config { func createTestConfig(t *testing.T) *config.Config {
@ -168,20 +166,11 @@ func setupTestHandlers(t *testing.T) *testContext {
) )
require.NoError(t, handlerErr) require.NoError(t, handlerErr)
mw, mwErr := middleware.New(fx.Lifecycle(nil), middleware.Params{
Logger: logInstance,
Globals: globalInstance,
Config: cfg,
Auth: authSvc,
})
require.NoError(t, mwErr)
return &testContext{ return &testContext{
handlers: handlersInstance, handlers: handlersInstance,
database: dbInstance, database: dbInstance,
authSvc: authSvc, authSvc: authSvc,
appSvc: appSvc, appSvc: appSvc,
middleware: mw,
} }
} }

View File

@ -1,34 +0,0 @@
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)
}
})
}
}

View File

@ -339,27 +339,6 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
} }
} }
// APISessionAuth returns middleware that requires session authentication for API routes.
// Unlike SessionAuth, it returns JSON 401 responses instead of redirecting to /login.
func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
user, err := m.params.Auth.GetCurrentUser(request.Context(), request)
if err != nil || user == nil {
writer.Header().Set("Content-Type", "application/json")
http.Error(writer, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(writer, request)
})
}
}
// SetupRequired returns middleware that redirects to setup if no user exists. // SetupRequired returns middleware that redirects to setup if no user exists.
func (m *Middleware) SetupRequired() func(http.Handler) http.Handler { func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {

View File

@ -14,7 +14,7 @@ import (
const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret, const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status, ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash, docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
previous_image_id, created_at, updated_at` created_at, updated_at`
// AppStatus represents the status of an app. // AppStatus represents the status of an app.
type AppStatus string type AppStatus string
@ -41,9 +41,8 @@ type App struct {
WebhookSecretHash string WebhookSecretHash string
SSHPrivateKey string SSHPrivateKey string
SSHPublicKey string SSHPublicKey string
ImageID sql.NullString ImageID sql.NullString
PreviousImageID sql.NullString Status AppStatus
Status AppStatus
DockerNetwork sql.NullString DockerNetwork sql.NullString
NtfyTopic sql.NullString NtfyTopic sql.NullString
SlackWebhook sql.NullString SlackWebhook sql.NullString
@ -141,15 +140,13 @@ func (a *App) insert(ctx context.Context) error {
INSERT INTO apps ( INSERT INTO apps (
id, name, repo_url, branch, dockerfile_path, webhook_secret, id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status, ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash, docker_network, ntfy_topic, slack_webhook, webhook_secret_hash
previous_image_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := a.db.Exec(ctx, query, _, err := a.db.Exec(ctx, query,
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret, a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status, a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash,
a.PreviousImageID,
) )
if err != nil { if err != nil {
return err return err
@ -164,7 +161,6 @@ func (a *App) update(ctx context.Context) error {
name = ?, repo_url = ?, branch = ?, dockerfile_path = ?, name = ?, repo_url = ?, branch = ?, dockerfile_path = ?,
image_id = ?, status = ?, image_id = ?, status = ?,
docker_network = ?, ntfy_topic = ?, slack_webhook = ?, docker_network = ?, ntfy_topic = ?, slack_webhook = ?,
previous_image_id = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ?` WHERE id = ?`
@ -172,7 +168,6 @@ func (a *App) update(ctx context.Context) error {
a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.Name, a.RepoURL, a.Branch, a.DockerfilePath,
a.ImageID, a.Status, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
a.PreviousImageID,
a.ID, a.ID,
) )
@ -187,7 +182,6 @@ func (a *App) scan(row *sql.Row) error {
&a.ImageID, &a.Status, &a.ImageID, &a.Status,
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook, &a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
&a.WebhookSecretHash, &a.WebhookSecretHash,
&a.PreviousImageID,
&a.CreatedAt, &a.UpdatedAt, &a.CreatedAt, &a.UpdatedAt,
) )
} }
@ -205,7 +199,6 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
&app.ImageID, &app.Status, &app.ImageID, &app.Status,
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook, &app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
&app.WebhookSecretHash, &app.WebhookSecretHash,
&app.PreviousImageID,
&app.CreatedAt, &app.UpdatedAt, &app.CreatedAt, &app.UpdatedAt,
) )
if scanErr != nil { if scanErr != nil {

View File

@ -54,71 +54,50 @@ func (s *Server) SetupRoutes() {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(s.mw.SessionAuth()) r.Use(s.mw.SessionAuth())
// Dashboard // Dashboard
r.Get("/", s.handlers.HandleDashboard()) r.Get("/", s.handlers.HandleDashboard())
// Logout // Logout
r.Post("/logout", s.handlers.HandleLogout()) r.Post("/logout", s.handlers.HandleLogout())
// App routes // App routes
r.Get("/apps/new", s.handlers.HandleAppNew()) r.Get("/apps/new", s.handlers.HandleAppNew())
r.Post("/apps", s.handlers.HandleAppCreate()) r.Post("/apps", s.handlers.HandleAppCreate())
r.Get("/apps/{id}", s.handlers.HandleAppDetail()) r.Get("/apps/{id}", s.handlers.HandleAppDetail())
r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit()) r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit())
r.Post("/apps/{id}", s.handlers.HandleAppUpdate()) r.Post("/apps/{id}", s.handlers.HandleAppUpdate())
r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete()) r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete())
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy()) r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy()) r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments()) r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI()) r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload()) r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs()) r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI()) r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI()) r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI()) r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI())
r.Post("/apps/{id}/rollback", s.handlers.HandleAppRollback()) r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart())
r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart()) 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 // Environment variables
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd()) 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}/edit", s.handlers.HandleEnvVarEdit())
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete()) 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())
r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit()) r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit())
r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete()) r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete())
// Volumes // Volumes
r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd()) r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd())
r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit()) r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit())
r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete()) r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete())
// Ports // Ports
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd()) r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete()) r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
})
})
// API v1 routes (cookie-based session auth, no CSRF)
s.router.Route("/api/v1", func(r chi.Router) {
// Login endpoint is public (returns session cookie)
r.With(s.mw.LoginRateLimit()).Post("/login", s.handlers.HandleAPILoginPOST())
// All other API routes require session auth
r.Group(func(r chi.Router) {
r.Use(s.mw.APISessionAuth())
r.Get("/whoami", s.handlers.HandleAPIWhoAmI())
r.Get("/apps", s.handlers.HandleAPIListApps())
r.Post("/apps", s.handlers.HandleAPICreateApp())
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp())
r.Delete("/apps/{id}", s.handlers.HandleAPIDeleteApp())
r.Post("/apps/{id}/deploy", s.handlers.HandleAPITriggerDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments())
}) })
}) })

View File

@ -49,8 +49,6 @@ var (
ErrBuildTimeout = errors.New("build timeout exceeded") ErrBuildTimeout = errors.New("build timeout exceeded")
// ErrDeployTimeout indicates the deploy phase exceeded the timeout. // ErrDeployTimeout indicates the deploy phase exceeded the timeout.
ErrDeployTimeout = errors.New("deploy timeout exceeded") ErrDeployTimeout = errors.New("deploy timeout exceeded")
// ErrNoPreviousImage indicates there is no previous image to rollback to.
ErrNoPreviousImage = errors.New("no previous image available for rollback")
) )
// logFlushInterval is how often to flush buffered logs to the database. // logFlushInterval is how often to flush buffered logs to the database.
@ -361,107 +359,6 @@ func (svc *Service) Deploy(
return svc.runBuildAndDeploy(deployCtx, bgCtx, app, deployment) return svc.runBuildAndDeploy(deployCtx, bgCtx, app, deployment)
} }
// Rollback rolls back an app to its previous image.
// It stops the current container, starts a new one with the previous image,
// and creates a deployment record for the rollback.
func (svc *Service) Rollback(ctx context.Context, app *models.App) error {
if !app.PreviousImageID.Valid || app.PreviousImageID.String == "" {
return ErrNoPreviousImage
}
// Acquire per-app deployment lock
if !svc.tryLockApp(app.ID) {
return ErrDeploymentInProgress
}
defer svc.unlockApp(app.ID)
bgCtx := context.WithoutCancel(ctx)
deployment, err := svc.createRollbackDeployment(bgCtx, app)
if err != nil {
return err
}
return svc.executeRollback(ctx, bgCtx, app, deployment)
}
// createRollbackDeployment creates a deployment record for a rollback operation.
func (svc *Service) createRollbackDeployment(
ctx context.Context,
app *models.App,
) (*models.Deployment, error) {
deployment := models.NewDeployment(svc.db)
deployment.AppID = app.ID
deployment.Status = models.DeploymentStatusDeploying
deployment.ImageID = sql.NullString{String: app.PreviousImageID.String, Valid: true}
saveErr := deployment.Save(ctx)
if saveErr != nil {
return nil, fmt.Errorf("failed to create rollback deployment: %w", saveErr)
}
_ = deployment.AppendLog(ctx, "Rolling back to previous image: "+app.PreviousImageID.String)
return deployment, nil
}
// executeRollback performs the container swap for a rollback.
func (svc *Service) executeRollback(
ctx context.Context,
bgCtx context.Context,
app *models.App,
deployment *models.Deployment,
) error {
previousImageID := app.PreviousImageID.String
svc.removeOldContainer(ctx, app, deployment)
rollbackOpts, err := svc.buildContainerOptions(ctx, app, deployment.ID)
if err != nil {
svc.failDeployment(bgCtx, app, deployment, err)
return fmt.Errorf("failed to build container options: %w", err)
}
rollbackOpts.Image = previousImageID
containerID, err := svc.docker.CreateContainer(ctx, rollbackOpts)
if err != nil {
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to create rollback container: %w", err))
return fmt.Errorf("failed to create rollback container: %w", err)
}
deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID)
startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil {
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to start rollback container: %w", startErr))
return fmt.Errorf("failed to start rollback container: %w", startErr)
}
_ = deployment.AppendLog(bgCtx, "Rollback container started")
currentImageID := app.ImageID
app.ImageID = sql.NullString{String: previousImageID, Valid: true}
app.PreviousImageID = currentImageID
app.Status = models.AppStatusRunning
saveErr := app.Save(bgCtx)
if saveErr != nil {
return fmt.Errorf("failed to update app after rollback: %w", saveErr)
}
_ = deployment.MarkFinished(bgCtx, models.DeploymentStatusSuccess)
_ = deployment.AppendLog(bgCtx, "Rollback complete")
svc.log.Info("rollback completed", "app", app.Name, "image", previousImageID)
return nil
}
// runBuildAndDeploy executes the build and deploy phases, handling cancellation. // runBuildAndDeploy executes the build and deploy phases, handling cancellation.
func (svc *Service) runBuildAndDeploy( func (svc *Service) runBuildAndDeploy(
deployCtx context.Context, deployCtx context.Context,
@ -493,11 +390,6 @@ func (svc *Service) runBuildAndDeploy(
return err return err
} }
// Save current image as previous before updating to new one
if app.ImageID.Valid && app.ImageID.String != "" {
app.PreviousImageID = app.ImageID
}
err = svc.updateAppRunning(bgCtx, app, imageID) err = svc.updateAppRunning(bgCtx, app, imageID)
if err != nil { if err != nil {
return err return err

View File

@ -57,10 +57,6 @@
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-success-500 text-white hover:bg-success-700 active:bg-green-800 focus:ring-green-500 shadow-elevation-1 hover:shadow-elevation-2; @apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-success-500 text-white hover:bg-success-700 active:bg-green-800 focus:ring-green-500 shadow-elevation-1 hover:shadow-elevation-2;
} }
.btn-warning {
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-warning-500 text-white hover:bg-warning-700 active:bg-orange-800 focus:ring-orange-500 shadow-elevation-1 hover:shadow-elevation-2;
}
.btn-text { .btn-text {
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed text-primary-600 hover:bg-primary-50 active:bg-primary-100; @apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed text-primary-600 hover:bg-primary-50 active:bg-primary-100;
} }

View File

@ -44,12 +44,6 @@
{{ .CSRFField }} {{ .CSRFField }}
<button type="submit" class="btn-danger">Cancel Deploy</button> <button type="submit" class="btn-danger">Cancel Deploy</button>
</form> </form>
{{if .App.PreviousImageID.Valid}}
<form method="POST" action="/apps/{{.App.ID}}/rollback" class="inline" x-data="confirmAction('Roll back to the previous deployment?')" @submit="confirm($event)">
{{ .CSRFField }}
<button type="submit" class="btn-warning">Rollback</button>
</form>
{{end}}
</div> </div>
</div> </div>
@ -123,21 +117,20 @@
<td class="text-right"> <td class="text-right">
<button @click="editing = true" 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>
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)"> <form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
{{ $.CSRFField }} {{ .CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button> <button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form> </form>
</td> </td>
</template> </template>
<template x-if="editing"> <template x-if="editing">
<td colspan="3"> <td colspan="3">
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/edit" class="flex gap-2 items-center"> <form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/edit" class="flex flex-col sm:flex-row gap-2 items-center">
{{ $.CSRFField }} {{ .CSRFField }}
<input type="text" name="key" value="{{.Key}}" 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"> <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="editing = false" 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>
</td> </td>
</template> </template>
</tr> </tr>
@ -187,15 +180,15 @@
<td class="text-right"> <td class="text-right">
<button @click="editing = true" 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>
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)"> <form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)">
{{ $.CSRFField }} {{ .CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button> <button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form> </form>
</td> </td>
</template> </template>
<template x-if="editing"> <template x-if="editing">
<td colspan="3"> <td colspan="3">
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/edit" class="flex gap-2 items-center"> <form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/edit" class="flex flex-col sm:flex-row gap-2 items-center">
{{ $.CSRFField }} {{ .CSRFField }}
<input type="text" name="key" value="{{.Key}}" 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"> <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>
@ -252,20 +245,20 @@
<td class="text-right"> <td class="text-right">
<button @click="editing = true" 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>
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)"> <form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)">
{{ $.CSRFField }} {{ .CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button> <button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form> </form>
</td> </td>
</template> </template>
<template x-if="editing"> <template x-if="editing">
<td colspan="4"> <td colspan="4">
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/edit" class="flex gap-2 items-center"> <form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/edit" class="flex flex-col sm:flex-row gap-2 items-center">
{{ $.CSRFField }} {{ .CSRFField }}
<input type="text" name="host_path" value="{{.HostPath}}" required class="input flex-1 font-mono text-sm" placeholder="/host/path"> <input type="text" name="host_path" value="{{.HostPath}}" required class="input flex-1 font-mono text-sm" placeholder="/host/path">
<input type="text" name="container_path" value="{{.ContainerPath}}" required class="input flex-1 font-mono text-sm" placeholder="/container/path"> <input type="text" name="container_path" value="{{.ContainerPath}}" required class="input flex-1 font-mono text-sm" placeholder="/container/path">
<label class="flex items-center gap-1 text-sm text-gray-600 whitespace-nowrap"> <label class="flex items-center gap-2 text-sm text-gray-600 whitespace-nowrap">
<input type="checkbox" name="readonly" value="1" {{if .ReadOnly}}checked{{end}} class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"> <input type="checkbox" name="readonly" value="1" {{if .ReadOnly}}checked{{end}} class="rounded border-gray-300 text-primary-600 focus:ring-primary-500">
RO Read-only
</label> </label>
<button type="submit" class="btn-primary text-sm">Save</button> <button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editing = false" 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>