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"
@ -37,7 +36,6 @@ type testContext struct {
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
@ -42,7 +42,6 @@ type App struct {
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
@ -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

@ -76,7 +76,6 @@ func (s *Server) SetupRoutes() {
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())
@ -102,26 +101,6 @@ func (s *Server) SetupRoutes() {
}) })
}) })
// 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())
})
})
// Metrics endpoint (optional, with basic auth) // Metrics endpoint (optional, with basic auth)
if s.params.Config.MetricsUsername != "" { if s.params.Config.MetricsUsername != "" {
s.router.Group(func(r chi.Router) { s.router.Group(func(r chi.Router) {

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>