2 Commits

Author SHA1 Message Date
81c109914b Merge branch 'main' into feature/json-api 2026-02-16 09:24:24 +01:00
user
a9829ce48f feat: add JSON API with token auth (closes #69)
- Add API token model with SHA-256 hashed tokens
- Add migration 006_add_api_tokens.sql
- Add Bearer token auth middleware
- Add API endpoints under /api/v1/:
  - GET /whoami
  - POST /tokens (create new API token)
  - GET /apps (list all apps)
  - POST /apps (create app)
  - GET /apps/{id} (get app)
  - DELETE /apps/{id} (delete app)
  - POST /apps/{id}/deploy (trigger deployment)
  - GET /apps/{id}/deployments (list deployments)
- Add comprehensive tests for all API endpoints
- All tests pass, zero lint issues
2026-02-16 00:20:41 -08:00
38 changed files with 1642 additions and 5282 deletions

View File

@@ -1,26 +0,0 @@
name: Check
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee # v2.10.1
- name: Install goimports
run: go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 # v0.42.0
- name: Run make check
run: make check

View File

@@ -176,39 +176,8 @@ docker run -d \
upaas upaas
``` ```
### Docker Compose **Important**: When running µPaaS inside a container, set `UPAAS_HOST_DATA_DIR` to the host path
that maps to `UPAAS_DATA_DIR`. This is required for Docker bind mounts during builds to work correctly.
```yaml
services:
upaas:
build: .
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${HOST_DATA_DIR}:/var/lib/upaas
environment:
- UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR}
# Optional: uncomment to enable debug logging
# - DEBUG=true
# Optional: Sentry error reporting
# - SENTRY_DSN=https://...
# Optional: Prometheus metrics auth
# - METRICS_USERNAME=prometheus
# - METRICS_PASSWORD=secret
```
**Important**: Set `HOST_DATA_DIR` to an **absolute path** on the Docker host before running
`docker compose up`. Relative paths will not work because docker-compose may not run on the same
machine as µPaaS. This value is used both for the bind mount and passed to µPaaS as
`UPAAS_HOST_DATA_DIR` so it can create correct bind mounts during builds.
Example:
```bash
export HOST_DATA_DIR=/srv/upaas-data
docker compose up -d
```
Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`. Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`.

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

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
upaas:
build: .
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- upaas-data:/var/lib/upaas
# environment:
# Optional: uncomment to enable debug logging
# - DEBUG=true
# Optional: Sentry error reporting
# - SENTRY_DSN=https://...
# Optional: Prometheus metrics auth
# - METRICS_USERNAME=prometheus
# - METRICS_PASSWORD=secret
volumes:
upaas-data:

View File

@@ -51,8 +51,7 @@ type Config struct {
MaintenanceMode bool MaintenanceMode bool
MetricsUsername string MetricsUsername string
MetricsPassword string MetricsPassword string
SessionSecret string `json:"-"` SessionSecret string
CORSOrigins string
params *Params params *Params
log *slog.Logger log *slog.Logger
} }
@@ -103,7 +102,6 @@ func setupViper(name string) {
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("SESSION_SECRET", "") viper.SetDefault("SESSION_SECRET", "")
viper.SetDefault("CORS_ORIGINS", "")
} }
func buildConfig(log *slog.Logger, params *Params) (*Config, error) { func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
@@ -138,7 +136,6 @@ func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
SessionSecret: viper.GetString("SESSION_SECRET"), SessionSecret: viper.GetString("SESSION_SECRET"),
CORSOrigins: viper.GetString("CORS_ORIGINS"),
params: params, params: params,
log: log, log: log,
} }

View File

@@ -113,9 +113,9 @@ func (d *Database) applyMigration(ctx context.Context, filename string) error {
return fmt.Errorf("failed to record migration: %w", err) return fmt.Errorf("failed to record migration: %w", err)
} }
err = transaction.Commit() commitErr := transaction.Commit()
if err != nil { if commitErr != nil {
return fmt.Errorf("failed to commit migration: %w", err) return fmt.Errorf("failed to commit migration: %w", commitErr)
} }
return nil return nil

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
token_hash TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);

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

@@ -14,10 +14,9 @@ import (
"strconv" "strconv"
"strings" "strings"
dockertypes "github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/docker/docker/client" "github.com/docker/docker/client"
@@ -116,7 +115,7 @@ type BuildImageOptions struct {
func (c *Client) BuildImage( func (c *Client) BuildImage(
ctx context.Context, ctx context.Context,
opts BuildImageOptions, opts BuildImageOptions,
) (ImageID, error) { ) (string, error) {
if c.docker == nil { if c.docker == nil {
return "", ErrNotConnected return "", ErrNotConnected
} }
@@ -188,7 +187,7 @@ func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
func (c *Client) CreateContainer( func (c *Client) CreateContainer(
ctx context.Context, ctx context.Context,
opts CreateContainerOptions, opts CreateContainerOptions,
) (ContainerID, error) { ) (string, error) {
if c.docker == nil { if c.docker == nil {
return "", ErrNotConnected return "", ErrNotConnected
} }
@@ -241,18 +240,18 @@ func (c *Client) CreateContainer(
return "", fmt.Errorf("failed to create container: %w", err) return "", fmt.Errorf("failed to create container: %w", err)
} }
return ContainerID(resp.ID), nil return resp.ID, nil
} }
// StartContainer starts a container. // StartContainer starts a container.
func (c *Client) StartContainer(ctx context.Context, containerID ContainerID) error { func (c *Client) StartContainer(ctx context.Context, containerID string) error {
if c.docker == nil { if c.docker == nil {
return ErrNotConnected return ErrNotConnected
} }
c.log.Info("starting container", "id", containerID) c.log.Info("starting container", "id", containerID)
err := c.docker.ContainerStart(ctx, string(containerID), container.StartOptions{}) err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
if err != nil { if err != nil {
return fmt.Errorf("failed to start container: %w", err) return fmt.Errorf("failed to start container: %w", err)
} }
@@ -261,7 +260,7 @@ func (c *Client) StartContainer(ctx context.Context, containerID ContainerID) er
} }
// StopContainer stops a container. // StopContainer stops a container.
func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) error { func (c *Client) StopContainer(ctx context.Context, containerID string) error {
if c.docker == nil { if c.docker == nil {
return ErrNotConnected return ErrNotConnected
} }
@@ -270,7 +269,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) err
timeout := stopTimeoutSeconds timeout := stopTimeoutSeconds
err := c.docker.ContainerStop(ctx, string(containerID), container.StopOptions{Timeout: &timeout}) err := c.docker.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
if err != nil { if err != nil {
return fmt.Errorf("failed to stop container: %w", err) return fmt.Errorf("failed to stop container: %w", err)
} }
@@ -281,7 +280,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) err
// RemoveContainer removes a container. // RemoveContainer removes a container.
func (c *Client) RemoveContainer( func (c *Client) RemoveContainer(
ctx context.Context, ctx context.Context,
containerID ContainerID, containerID string,
force bool, force bool,
) error { ) error {
if c.docker == nil { if c.docker == nil {
@@ -290,7 +289,7 @@ func (c *Client) RemoveContainer(
c.log.Info("removing container", "id", containerID, "force", force) c.log.Info("removing container", "id", containerID, "force", force)
err := c.docker.ContainerRemove(ctx, string(containerID), container.RemoveOptions{Force: force}) err := c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
if err != nil { if err != nil {
return fmt.Errorf("failed to remove container: %w", err) return fmt.Errorf("failed to remove container: %w", err)
} }
@@ -301,7 +300,7 @@ func (c *Client) RemoveContainer(
// ContainerLogs returns the logs for a container. // ContainerLogs returns the logs for a container.
func (c *Client) ContainerLogs( func (c *Client) ContainerLogs(
ctx context.Context, ctx context.Context,
containerID ContainerID, containerID string,
tail string, tail string,
) (string, error) { ) (string, error) {
if c.docker == nil { if c.docker == nil {
@@ -314,7 +313,7 @@ func (c *Client) ContainerLogs(
Tail: tail, Tail: tail,
} }
reader, err := c.docker.ContainerLogs(ctx, string(containerID), opts) reader, err := c.docker.ContainerLogs(ctx, containerID, opts)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get container logs: %w", err) return "", fmt.Errorf("failed to get container logs: %w", err)
} }
@@ -337,13 +336,13 @@ func (c *Client) ContainerLogs(
// IsContainerRunning checks if a container is running. // IsContainerRunning checks if a container is running.
func (c *Client) IsContainerRunning( func (c *Client) IsContainerRunning(
ctx context.Context, ctx context.Context,
containerID ContainerID, containerID string,
) (bool, error) { ) (bool, error) {
if c.docker == nil { if c.docker == nil {
return false, ErrNotConnected return false, ErrNotConnected
} }
inspect, err := c.docker.ContainerInspect(ctx, string(containerID)) inspect, err := c.docker.ContainerInspect(ctx, containerID)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to inspect container: %w", err) return false, fmt.Errorf("failed to inspect container: %w", err)
} }
@@ -354,13 +353,13 @@ func (c *Client) IsContainerRunning(
// IsContainerHealthy checks if a container is healthy. // IsContainerHealthy checks if a container is healthy.
func (c *Client) IsContainerHealthy( func (c *Client) IsContainerHealthy(
ctx context.Context, ctx context.Context,
containerID ContainerID, containerID string,
) (bool, error) { ) (bool, error) {
if c.docker == nil { if c.docker == nil {
return false, ErrNotConnected return false, ErrNotConnected
} }
inspect, err := c.docker.ContainerInspect(ctx, string(containerID)) inspect, err := c.docker.ContainerInspect(ctx, containerID)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to inspect container: %w", err) return false, fmt.Errorf("failed to inspect container: %w", err)
} }
@@ -378,7 +377,7 @@ const LabelUpaasID = "upaas.id"
// ContainerInfo contains basic information about a container. // ContainerInfo contains basic information about a container.
type ContainerInfo struct { type ContainerInfo struct {
ID ContainerID ID string
Running bool Running bool
} }
@@ -413,7 +412,7 @@ func (c *Client) FindContainerByAppID(
ctr := containers[0] ctr := containers[0]
return &ContainerInfo{ return &ContainerInfo{
ID: ContainerID(ctr.ID), ID: ctr.ID,
Running: ctr.State == "running", Running: ctr.State == "running",
}, nil }, nil
} }
@@ -480,24 +479,10 @@ func (c *Client) CloneRepo(
return c.performClone(ctx, cfg) return c.performClone(ctx, cfg)
} }
// RemoveImage removes a Docker image by ID or tag.
// It returns nil if the image was successfully removed or does not exist.
func (c *Client) RemoveImage(ctx context.Context, imageID ImageID) error {
_, err := c.docker.ImageRemove(ctx, string(imageID), image.RemoveOptions{
Force: true,
PruneChildren: true,
})
if err != nil && !client.IsErrNotFound(err) {
return fmt.Errorf("failed to remove image %s: %w", imageID, err)
}
return nil
}
func (c *Client) performBuild( func (c *Client) performBuild(
ctx context.Context, ctx context.Context,
opts BuildImageOptions, opts BuildImageOptions,
) (ImageID, error) { ) (string, error) {
// Create tar archive of build context // Create tar archive of build context
tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{}) tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{})
if err != nil { if err != nil {
@@ -512,7 +497,7 @@ func (c *Client) performBuild(
}() }()
// Build image // Build image
resp, err := c.docker.ImageBuild(ctx, tarArchive, dockertypes.ImageBuildOptions{ resp, err := c.docker.ImageBuild(ctx, tarArchive, types.ImageBuildOptions{
Dockerfile: opts.DockerfilePath, Dockerfile: opts.DockerfilePath,
Tags: opts.Tags, Tags: opts.Tags,
Remove: true, Remove: true,
@@ -542,7 +527,7 @@ func (c *Client) performBuild(
return "", fmt.Errorf("failed to inspect image: %w", inspectErr) return "", fmt.Errorf("failed to inspect image: %w", inspectErr)
} }
return ImageID(inspect.ID), nil return inspect.ID, nil
} }
return "", nil return "", nil
@@ -603,22 +588,22 @@ func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResu
} }
}() }()
gitContainerID, err := c.createGitContainer(ctx, cfg) containerID, err := c.createGitContainer(ctx, cfg)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() { defer func() {
_ = c.docker.ContainerRemove(ctx, string(gitContainerID), container.RemoveOptions{Force: true}) _ = c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true})
}() }()
return c.runGitClone(ctx, gitContainerID) return c.runGitClone(ctx, containerID)
} }
func (c *Client) createGitContainer( func (c *Client) createGitContainer(
ctx context.Context, ctx context.Context,
cfg *cloneConfig, cfg *cloneConfig,
) (ContainerID, error) { ) (string, error) {
gitSSHCmd := "ssh -i /keys/deploy_key -o StrictHostKeyChecking=no" gitSSHCmd := "ssh -i /keys/deploy_key -o StrictHostKeyChecking=no"
// Build the git command using environment variables to avoid shell injection. // Build the git command using environment variables to avoid shell injection.
@@ -675,16 +660,16 @@ func (c *Client) createGitContainer(
return "", fmt.Errorf("failed to create git container: %w", err) return "", fmt.Errorf("failed to create git container: %w", err)
} }
return ContainerID(resp.ID), nil return resp.ID, nil
} }
func (c *Client) runGitClone(ctx context.Context, containerID ContainerID) (*CloneResult, error) { func (c *Client) runGitClone(ctx context.Context, containerID string) (*CloneResult, error) {
err := c.docker.ContainerStart(ctx, string(containerID), container.StartOptions{}) err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to start git container: %w", err) return nil, fmt.Errorf("failed to start git container: %w", err)
} }
statusCh, errCh := c.docker.ContainerWait(ctx, string(containerID), container.WaitConditionNotRunning) statusCh, errCh := c.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
select { select {
case err := <-errCh: case err := <-errCh:

View File

@@ -1,7 +0,0 @@
package docker
// ImageID is a Docker image identifier (ID or tag).
type ImageID string
// ContainerID is a Docker container identifier.
type ContainerID string

View File

@@ -7,7 +7,9 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/middleware"
"git.eeqj.de/sneak/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/app"
) )
// apiAppResponse is the JSON representation of an app. // apiAppResponse is the JSON representation of an app.
@@ -70,63 +72,6 @@ func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
return resp return resp
} }
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
// and sets a session cookie.
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
type loginResponse struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
}
return func(writer http.ResponseWriter, request *http.Request) {
var req map[string]string
decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid JSON body"},
http.StatusBadRequest)
return
}
username := req["username"]
credential := req["password"]
if username == "" || credential == "" {
h.respondJSON(writer, request,
map[string]string{"error": "username and password are required"},
http.StatusBadRequest)
return
}
user, authErr := h.auth.Authenticate(request.Context(), username, credential)
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. // HandleAPIListApps returns a handler that lists all apps as JSON.
func (h *Handlers) HandleAPIListApps() http.HandlerFunc { func (h *Handlers) HandleAPIListApps() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@@ -174,6 +119,106 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
} }
} }
// 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. // deploymentsPageLimit is the default number of deployments per page.
const deploymentsPageLimit = 20 const deploymentsPageLimit = 20
@@ -220,6 +265,88 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
} }
} }
// 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)
}
}
// HandleAPICreateToken returns a handler that creates an API token.
func (h *Handlers) HandleAPICreateToken() http.HandlerFunc {
type createTokenRequest struct {
Name string `json:"name"`
}
type createTokenResponse struct {
Token string `json:"token"`
Name string `json:"name"`
ID int64 `json:"id"`
}
return func(writer http.ResponseWriter, request *http.Request) {
user := middleware.APIUserFromContext(request.Context())
if user == nil {
h.respondJSON(writer, request,
map[string]string{"error": "unauthorized"},
http.StatusUnauthorized)
return
}
var req createTokenRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil {
req.Name = "default"
}
if req.Name == "" {
req.Name = "default"
}
rawToken, token, err := models.GenerateAPIToken(
request.Context(), h.db, user.ID, req.Name,
)
if err != nil {
h.log.Error("api: failed to create token", "error", err)
h.respondJSON(writer, request,
map[string]string{"error": "failed to create token"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, createTokenResponse{
Token: rawToken,
Name: token.Name,
ID: token.ID,
}, http.StatusCreated)
}
}
// HandleAPIWhoAmI returns a handler that shows the current authenticated user. // HandleAPIWhoAmI returns a handler that shows the current authenticated user.
func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc { func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
type whoAmIResponse struct { type whoAmIResponse struct {
@@ -228,8 +355,8 @@ func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
} }
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
user, err := h.auth.GetCurrentUser(request.Context(), request) user := middleware.APIUserFromContext(request.Context())
if err != nil || user == nil { if user == nil {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{"error": "unauthorized"}, map[string]string{"error": "unauthorized"},
http.StatusUnauthorized) http.StatusUnauthorized)

View File

@@ -11,163 +11,103 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"git.eeqj.de/sneak/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/internal/models"
) )
// apiRouter builds a chi router with the API routes using session auth middleware. func setupAPITest(t *testing.T) (*testContext, string) {
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.Get("/apps/{id}", tc.handlers.HandleAPIGetApp())
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() t.Helper()
tc := setupTestHandlers(t) tc := setupTestHandlers(t)
// Create a user. // Create a user first.
_, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123") _, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123")
require.NoError(t, err) require.NoError(t, err)
// Login via the API to get session cookies. user, err := models.FindUserByUsername(t.Context(), tc.database, "admin")
r := apiRouter(tc) require.NoError(t, err)
require.NotNil(t, user)
loginBody := `{"username":"admin","password":"password123"}` // Generate an API token.
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(loginBody)) rawToken, _, err := models.GenerateAPIToken(t.Context(), tc.database, user.ID, "test")
req.Header.Set("Content-Type", "application/json") require.NoError(t, err)
rr := httptest.NewRecorder() return tc, rawToken
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
} }
// apiGet makes an authenticated GET request using session cookies. func apiRequest(
func apiGet(
t *testing.T, t *testing.T,
tc *testContext, tc *testContext,
cookies []*http.Cookie, token, method, path string,
path string, body string,
) *httptest.ResponseRecorder { ) *httptest.ResponseRecorder {
t.Helper() t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil) var req *http.Request
if body != "" {
for _, c := range cookies { req = httptest.NewRequest(method, path, strings.NewReader(body))
req.AddCookie(c) req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
} }
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
r := apiRouter(tc) // Build a chi router with API routes.
r := chi.NewRouter()
mw := tc.middleware
r.Route("/api/v1", func(apiR chi.Router) {
apiR.Use(mw.APITokenAuth())
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
apiR.Post("/tokens", tc.handlers.HandleAPICreateToken())
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())
})
r.ServeHTTP(rr, req) r.ServeHTTP(rr, req)
return rr return rr
} }
func TestAPILoginSuccess(t *testing.T) { func TestAPIAuthRejectsNoToken(t *testing.T) {
t.Parallel() t.Parallel()
tc := setupTestHandlers(t) 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) req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
r := chi.NewRouter()
r.Route("/api/v1", func(apiR chi.Router) {
apiR.Use(tc.middleware.APITokenAuth())
apiR.Get("/apps", tc.handlers.HandleAPIListApps())
})
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestAPIAuthRejectsInvalidToken(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
rr := apiRequest(t, tc, "invalid-token", http.MethodGet, "/api/v1/apps", "")
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
} }
func TestAPIWhoAmI(t *testing.T) { func TestAPIWhoAmI(t *testing.T) {
t.Parallel() t.Parallel()
tc, cookies := setupAPITest(t) tc, token := setupAPITest(t)
rr := apiGet(t, tc, cookies, "/api/v1/whoami") rr := apiRequest(t, tc, token, http.MethodGet, "/api/v1/whoami", "")
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
var resp map[string]any var resp map[string]any
@@ -178,9 +118,9 @@ func TestAPIWhoAmI(t *testing.T) {
func TestAPIListAppsEmpty(t *testing.T) { func TestAPIListAppsEmpty(t *testing.T) {
t.Parallel() t.Parallel()
tc, cookies := setupAPITest(t) tc, token := setupAPITest(t)
rr := apiGet(t, tc, cookies, "/api/v1/apps") rr := apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps", "")
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
var apps []any var apps []any
@@ -188,49 +128,127 @@ func TestAPIListAppsEmpty(t *testing.T) {
assert.Empty(t, apps) assert.Empty(t, apps)
} }
func TestAPICreateApp(t *testing.T) {
t.Parallel()
tc, token := setupAPITest(t)
body := `{"name":"test-app","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, token, 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, token := setupAPITest(t)
// Missing required fields.
body := `{"name":"","repoUrl":""}`
rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/apps", body)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestAPIGetApp(t *testing.T) { func TestAPIGetApp(t *testing.T) {
t.Parallel() t.Parallel()
tc, cookies := setupAPITest(t) tc, token := setupAPITest(t)
created, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{ // Create an app first.
Name: "my-app", body := `{"name":"my-app","repoUrl":"https://github.com/example/repo"}`
RepoURL: "https://github.com/example/repo", rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/apps", body)
}) require.Equal(t, http.StatusCreated, rr.Code)
require.NoError(t, err)
rr := apiGet(t, tc, cookies, "/api/v1/apps/"+created.ID) var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
// Get the app.
rr = apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
var resp map[string]any var app map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
assert.Equal(t, "my-app", resp["name"]) assert.Equal(t, "my-app", app["name"])
} }
func TestAPIGetAppNotFound(t *testing.T) { func TestAPIGetAppNotFound(t *testing.T) {
t.Parallel() t.Parallel()
tc, cookies := setupAPITest(t) tc, token := setupAPITest(t)
rr := apiGet(t, tc, cookies, "/api/v1/apps/nonexistent") rr := apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps/nonexistent", "")
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestAPIDeleteApp(t *testing.T) {
t.Parallel()
tc, token := setupAPITest(t)
// Create an app.
body := `{"name":"delete-me","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, token, 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)
// Delete it.
rr = apiRequest(t, tc, token, http.MethodDelete, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusOK, rr.Code)
// Verify it's gone.
rr = apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
} }
func TestAPIListDeployments(t *testing.T) { func TestAPIListDeployments(t *testing.T) {
t.Parallel() t.Parallel()
tc, cookies := setupAPITest(t) tc, token := setupAPITest(t)
created, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{ // Create an app.
Name: "deploy-app", body := `{"name":"deploy-app","repoUrl":"https://github.com/example/repo"}`
RepoURL: "https://github.com/example/repo", rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/apps", body)
}) require.Equal(t, http.StatusCreated, rr.Code)
require.NoError(t, err)
rr := apiGet(t, tc, cookies, "/api/v1/apps/"+created.ID+"/deployments") var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
// List deployments (should be empty).
rr = apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps/"+appID+"/deployments", "")
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
var deployments []any var deployments []any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &deployments)) require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &deployments))
assert.Empty(t, deployments) assert.Empty(t, deployments)
} }
func TestAPICreateToken(t *testing.T) {
t.Parallel()
tc, token := setupAPITest(t)
body := `{"name":"new-token"}`
rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/tokens", body)
assert.Equal(t, http.StatusCreated, rr.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
assert.Equal(t, "new-token", resp["name"])
assert.NotEmpty(t, resp["token"])
}

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"
@@ -72,15 +70,7 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
nameErr := validateAppName(name) nameErr := validateAppName(name)
if nameErr != nil { if nameErr != nil {
data["Error"] = "Invalid app name: " + nameErr.Error() data["Error"] = "Invalid app name: " + nameErr.Error()
h.renderTemplate(writer, tmpl, "app_new.html", data) _ = tmpl.ExecuteTemplate(writer, "app_new.html", data)
return
}
repoURLErr := validateRepoURL(repoURL)
if repoURLErr != nil {
data["Error"] = "Invalid repository URL: " + repoURLErr.Error()
h.renderTemplate(writer, tmpl, "app_new.html", data)
return return
} }
@@ -228,18 +218,7 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
"App": application, "App": application,
"Error": "Invalid app name: " + nameErr.Error(), "Error": "Invalid app name: " + nameErr.Error(),
}, request) }, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data) _ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
return
}
repoURLErr := validateRepoURL(request.FormValue("repo_url"))
if repoURLErr != nil {
data := h.addGlobals(map[string]any{
"App": application,
"Error": "Invalid repository URL: " + repoURLErr.Error(),
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
return return
} }
@@ -401,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()
@@ -518,7 +473,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return return
} }
_, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- logs sanitized, Content-Type is text/plain _, _ = writer.Write([]byte(logs))
} }
} }
@@ -553,7 +508,7 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
logs := "" logs := ""
if deployment.Logs.Valid { if deployment.Logs.Valid {
logs = SanitizeLogs(deployment.Logs.String) logs = deployment.Logs.String
} }
response := map[string]any{ response := map[string]any{
@@ -600,8 +555,8 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
return return
} }
// Check if file exists — logPath is constructed internally, not from user input // Check if file exists
_, err := os.Stat(logPath) // #nosec G703 -- path from internal GetLogFilePath, not user input _, err := os.Stat(logPath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
http.NotFound(writer, request) http.NotFound(writer, request)
@@ -680,7 +635,7 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
} }
response := map[string]any{ response := map[string]any{
"logs": SanitizeLogs(logs), "logs": logs,
"status": status, "status": status,
} }
@@ -916,7 +871,7 @@ func (h *Handlers) HandleEnvVarAdd() http.HandlerFunc {
func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc { func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id") appID := chi.URLParam(request, "id")
envVarIDStr := chi.URLParam(request, "varID") envVarIDStr := chi.URLParam(request, "envID")
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64) envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
if parseErr != nil { if parseErr != nil {
@@ -1022,14 +977,6 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
return return
} }
pathErr := validateVolumePaths(hostPath, containerPath)
if pathErr != nil {
h.log.Error("invalid volume path", "error", pathErr)
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
volume := models.NewVolume(h.db) volume := models.NewVolume(h.db)
volume.AppID = application.ID volume.AppID = application.ID
volume.HostPath = hostPath volume.HostPath = hostPath
@@ -1169,207 +1116,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

@@ -1,6 +0,0 @@
package handlers
// ValidateRepoURLForTest exports validateRepoURL for testing.
func ValidateRepoURLForTest(repoURL string) error {
return validateRepoURL(repoURL)
}

View File

@@ -173,6 +173,7 @@ func setupTestHandlers(t *testing.T) *testContext {
Globals: globalInstance, Globals: globalInstance,
Config: cfg, Config: cfg,
Auth: authSvc, Auth: authSvc,
Database: dbInstance,
}) })
require.NoError(t, mwErr) require.NoError(t, mwErr)
@@ -564,7 +565,7 @@ func TestDeleteEnvVarOwnershipVerification(t *testing.T) { //nolint:dupl // inte
return "/apps/" + appID + "/env/" + strconv.FormatInt(resourceID, 10) + "/delete" return "/apps/" + appID + "/env/" + strconv.FormatInt(resourceID, 10) + "/delete"
}, },
chiParams: func(appID string, resourceID int64) map[string]string { chiParams: func(appID string, resourceID int64) map[string]string {
return map[string]string{"id": appID, "varID": strconv.FormatInt(resourceID, 10)} return map[string]string{"id": appID, "envID": strconv.FormatInt(resourceID, 10)}
}, },
handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleEnvVarDelete() }, handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleEnvVarDelete() },
verifyFn: func(t *testing.T, tc *testContext, resourceID int64) { verifyFn: func(t *testing.T, tc *testContext, resourceID int64) {
@@ -695,153 +696,6 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
assert.NotNil(t, found, "port should still exist after IDOR attempt") assert.NotNil(t, found, "port should still exist after IDOR attempt")
} }
// TestHandleEnvVarDeleteUsesCorrectRouteParam verifies that HandleEnvVarDelete
// reads the "varID" chi URL parameter (matching the route definition {varID}),
// not a mismatched name like "envID".
func TestHandleEnvVarDeleteUsesCorrectRouteParam(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envdelete-param-app")
envVar := models.NewEnvVar(testCtx.database)
envVar.AppID = createdApp.ID
envVar.Key = "DELETE_ME"
envVar.Value = "gone"
require.NoError(t, envVar.Save(context.Background()))
// Use chi router with the real route pattern to test param name
r := chi.NewRouter()
r.Post("/apps/{id}/env-vars/{varID}/delete", testCtx.handlers.HandleEnvVarDelete())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env-vars/"+strconv.FormatInt(envVar.ID, 10)+"/delete",
nil,
)
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code)
// Verify the env var was actually deleted
found, findErr := models.FindEnvVar(context.Background(), testCtx.database, envVar.ID)
require.NoError(t, findErr)
assert.Nil(t, found, "env var should be deleted when using correct route param")
}
// TestHandleVolumeAddValidatesPaths verifies that HandleVolumeAdd validates
// host and container paths (same as HandleVolumeEdit).
func TestHandleVolumeAddValidatesPaths(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "volume-validate-app")
tests := []struct {
name string
hostPath string
containerPath string
shouldCreate bool
}{
{"relative host path rejected", "relative/path", "/container", false},
{"relative container path rejected", "/host", "relative/path", false},
{"unclean host path rejected", "/host/../etc", "/container", false},
{"valid paths accepted", "/host/data", "/container/data", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
form := url.Values{}
form.Set("host_path", tt.hostPath)
form.Set("container_path", tt.containerPath)
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/volumes",
strings.NewReader(form.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleVolumeAdd()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code)
// Check if volume was created by listing volumes
volumes, _ := createdApp.GetVolumes(context.Background())
found := false
for _, v := range volumes {
if v.HostPath == tt.hostPath && v.ContainerPath == tt.containerPath {
found = true
// Clean up for isolation
_ = v.Delete(context.Background())
}
}
if tt.shouldCreate {
assert.True(t, found, "volume should be created for valid paths")
} else {
assert.False(t, found, "volume should NOT be created for invalid paths")
}
})
}
}
// TestSetupRequiredExemptsHealthAndStaticAndAPI verifies that the SetupRequired
// middleware allows /health, /s/*, and /api/* paths through even when setup is required.
func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// No user created, so setup IS required
mw := testCtx.middleware.SetupRequired()
okHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
})
wrapped := mw(okHandler)
exemptPaths := []string{"/health", "/s/style.css", "/s/js/app.js", "/api/v1/apps", "/api/v1/login"}
for _, path := range exemptPaths {
t.Run(path, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, path, nil)
rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code,
"path %s should be exempt from setup redirect", path)
})
}
// Non-exempt path should redirect to /setup
t.Run("non-exempt redirects", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req)
assert.Equal(t, http.StatusSeeOther, rr.Code)
assert.Equal(t, "/setup", rr.Header().Get("Location"))
})
}
func TestHandleCancelDeployRedirects(t *testing.T) { func TestHandleCancelDeployRedirects(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -1,77 +0,0 @@
package handlers
import (
"errors"
"net/url"
"regexp"
"strings"
)
// Repo URL validation errors.
var (
errRepoURLEmpty = errors.New("repository URL must not be empty")
errRepoURLScheme = errors.New("file:// URLs are not allowed for security reasons")
errRepoURLInvalid = errors.New("repository URL must use https://, http://, ssh://, git://, or git@host:path format")
errRepoURLNoHost = errors.New("repository URL must include a host")
errRepoURLNoPath = errors.New("repository URL must include a path")
)
// scpLikeRepoRe matches SCP-like git URLs: git@host:path (e.g. git@github.com:user/repo.git).
// Only the "git" user is allowed, as that is the standard for SSH deploy keys.
var scpLikeRepoRe = regexp.MustCompile(`^git@[a-zA-Z0-9._-]+:.+$`)
// allowedRepoSchemes lists the URL schemes accepted for repository URLs.
//
//nolint:gochecknoglobals // package-level constant map parsed once
var allowedRepoSchemes = map[string]bool{
"https": true,
"http": true,
"ssh": true,
"git": true,
}
// validateRepoURL checks that the given repository URL is valid and uses an allowed scheme.
func validateRepoURL(repoURL string) error {
if strings.TrimSpace(repoURL) == "" {
return errRepoURLEmpty
}
// Reject path traversal in any URL format
if strings.Contains(repoURL, "..") {
return errRepoURLInvalid
}
// Check for SCP-like git URLs first (git@host:path)
if scpLikeRepoRe.MatchString(repoURL) {
return nil
}
// Reject file:// explicitly
if strings.HasPrefix(strings.ToLower(repoURL), "file://") {
return errRepoURLScheme
}
return validateParsedRepoURL(repoURL)
}
// validateParsedRepoURL validates a standard URL-format repository URL.
func validateParsedRepoURL(repoURL string) error {
parsed, err := url.Parse(repoURL)
if err != nil {
return errRepoURLInvalid
}
if !allowedRepoSchemes[strings.ToLower(parsed.Scheme)] {
return errRepoURLInvalid
}
if parsed.Host == "" {
return errRepoURLNoHost
}
if parsed.Path == "" || parsed.Path == "/" {
return errRepoURLNoPath
}
return nil
}

View File

@@ -1,60 +0,0 @@
package handlers_test
import (
"testing"
"git.eeqj.de/sneak/upaas/internal/handlers"
)
func TestValidateRepoURL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
url string
wantErr bool
}{
// Valid URLs
{name: "https URL", url: "https://github.com/user/repo.git", wantErr: false},
{name: "http URL", url: "http://github.com/user/repo.git", wantErr: false},
{name: "ssh URL", url: "ssh://git@github.com/user/repo.git", wantErr: false},
{name: "git URL", url: "git://github.com/user/repo.git", wantErr: false},
{name: "SCP-like URL", url: "git@github.com:user/repo.git", wantErr: false},
{name: "SCP-like with dots", url: "git@git.example.com:org/repo.git", wantErr: false},
{name: "https without .git", url: "https://github.com/user/repo", wantErr: false},
{name: "https with port", url: "https://git.example.com:8443/user/repo.git", wantErr: false},
// Invalid URLs
{name: "empty string", url: "", wantErr: true},
{name: "whitespace only", url: " ", wantErr: true},
{name: "file URL", url: "file:///etc/passwd", wantErr: true},
{name: "file URL uppercase", url: "FILE:///etc/passwd", wantErr: true},
{name: "bare path", url: "/some/local/path", wantErr: true},
{name: "relative path", url: "../repo", wantErr: true},
{name: "just a word", url: "notaurl", wantErr: true},
{name: "ftp URL", url: "ftp://example.com/repo.git", wantErr: true},
{name: "no host https", url: "https:///path", wantErr: true},
{name: "no path https", url: "https://github.com", wantErr: true},
{name: "no path https trailing slash", url: "https://github.com/", wantErr: true},
{name: "SCP-like non-git user", url: "root@github.com:user/repo.git", wantErr: true},
{name: "SCP-like arbitrary user", url: "admin@github.com:user/repo.git", wantErr: true},
{name: "path traversal SCP", url: "git@github.com:../../etc/passwd", wantErr: true},
{name: "path traversal https", url: "https://github.com/user/../../../etc/passwd", wantErr: true},
{name: "path traversal in middle", url: "https://github.com/user/repo/../secret", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := handlers.ValidateRepoURLForTest(tc.url)
if tc.wantErr && err == nil {
t.Errorf("ValidateRepoURLForTest(%q) = nil, want error", tc.url)
}
if !tc.wantErr && err != nil {
t.Errorf("ValidateRepoURLForTest(%q) = %v, want nil", tc.url, err)
}
})
}
}

View File

@@ -1,30 +0,0 @@
package handlers
import (
"regexp"
"strings"
)
// ansiEscapePattern matches ANSI escape sequences (CSI, OSC, and single-character escapes).
var ansiEscapePattern = regexp.MustCompile(`(\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]])`)
// SanitizeLogs strips ANSI escape sequences and non-printable control characters
// from container log output. Newlines (\n), carriage returns (\r), and tabs (\t)
// are preserved. This ensures that attacker-controlled container output cannot
// inject terminal escape sequences or other dangerous control characters.
func SanitizeLogs(input string) string {
// Strip ANSI escape sequences
result := ansiEscapePattern.ReplaceAllString(input, "")
// Strip remaining non-printable characters (keep \n, \r, \t)
var b strings.Builder
b.Grow(len(result))
for _, r := range result {
if r == '\n' || r == '\r' || r == '\t' || r >= ' ' {
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -1,84 +0,0 @@
package handlers_test
import (
"testing"
"git.eeqj.de/sneak/upaas/internal/handlers"
)
func TestSanitizeLogs(t *testing.T) { //nolint:funlen // table-driven tests
t.Parallel()
tests := []struct {
name string
input string
expected string
}{
{
name: "plain text unchanged",
input: "hello world\n",
expected: "hello world\n",
},
{
name: "strips ANSI color codes",
input: "\x1b[31mERROR\x1b[0m: something failed\n",
expected: "ERROR: something failed\n",
},
{
name: "strips OSC sequences",
input: "\x1b]0;window title\x07normal text\n",
expected: "normal text\n",
},
{
name: "strips null bytes",
input: "hello\x00world\n",
expected: "helloworld\n",
},
{
name: "strips bell characters",
input: "alert\x07here\n",
expected: "alerthere\n",
},
{
name: "preserves tabs",
input: "field1\tfield2\tfield3\n",
expected: "field1\tfield2\tfield3\n",
},
{
name: "preserves carriage returns",
input: "line1\r\nline2\r\n",
expected: "line1\r\nline2\r\n",
},
{
name: "strips mixed escape sequences",
input: "\x1b[32m2024-01-01\x1b[0m \x1b[1mINFO\x1b[0m starting\x00\n",
expected: "2024-01-01 INFO starting\n",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "only control characters",
input: "\x00\x01\x02\x03",
expected: "",
},
{
name: "cursor movement sequences stripped",
input: "\x1b[2J\x1b[H\x1b[3Atext\n",
expected: "text\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := handlers.SanitizeLogs(tt.input)
if got != tt.expected {
t.Errorf("SanitizeLogs(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}

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

@@ -1,81 +0,0 @@
package middleware //nolint:testpackage // tests internal CORS behavior
import (
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"git.eeqj.de/sneak/upaas/internal/config"
)
//nolint:gosec // test credentials
func newCORSTestMiddleware(corsOrigins string) *Middleware {
return &Middleware{
log: slog.Default(),
params: &Params{
Config: &config.Config{
CORSOrigins: corsOrigins,
SessionSecret: "test-secret-32-bytes-long-enough",
},
},
}
}
func TestCORS_NoOriginsConfigured_NoCORSHeaders(t *testing.T) {
t.Parallel()
m := newCORSTestMiddleware("")
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://evil.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"),
"expected no CORS headers when no origins configured")
}
func TestCORS_OriginsConfigured_AllowsMatchingOrigin(t *testing.T) {
t.Parallel()
m := newCORSTestMiddleware("https://app.example.com,https://other.example.com")
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://app.example.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, "https://app.example.com",
rec.Header().Get("Access-Control-Allow-Origin"))
assert.Equal(t, "true",
rec.Header().Get("Access-Control-Allow-Credentials"))
}
func TestCORS_OriginsConfigured_RejectsNonMatchingOrigin(t *testing.T) {
t.Parallel()
m := newCORSTestMiddleware("https://app.example.com")
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://evil.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"),
"expected no CORS headers for non-matching origin")
}

View File

@@ -2,6 +2,7 @@
package middleware package middleware
import ( import (
"context"
"log/slog" "log/slog"
"math" "math"
"net" "net"
@@ -19,14 +20,19 @@ import (
"golang.org/x/time/rate" "golang.org/x/time/rate"
"git.eeqj.de/sneak/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/auth"
) )
// corsMaxAge is the maximum age for CORS preflight responses in seconds. // corsMaxAge is the maximum age for CORS preflight responses in seconds.
const corsMaxAge = 300 const corsMaxAge = 300
// apiUserContextKey is the context key for the authenticated API user.
type apiUserContextKey struct{}
// Params contains dependencies for Middleware. // Params contains dependencies for Middleware.
type Params struct { type Params struct {
fx.In fx.In
@@ -35,6 +41,7 @@ type Params struct {
Globals *globals.Globals Globals *globals.Globals
Config *config.Config Config *config.Config
Auth *auth.Service Auth *auth.Service
Database *database.Database
} }
// Middleware provides HTTP middleware. // Middleware provides HTTP middleware.
@@ -177,48 +184,17 @@ func realIP(r *http.Request) string {
} }
// CORS returns CORS middleware. // CORS returns CORS middleware.
// When UPAAS_CORS_ORIGINS is empty (default), no CORS headers are sent
// (same-origin only). When configured, only the specified origins are
// allowed and credentials (cookies) are permitted.
func (m *Middleware) CORS() func(http.Handler) http.Handler { func (m *Middleware) CORS() func(http.Handler) http.Handler {
origins := parseCORSOrigins(m.params.Config.CORSOrigins)
// No origins configured — no CORS headers (same-origin policy).
if len(origins) == 0 {
return func(next http.Handler) http.Handler {
return next
}
}
return cors.Handler(cors.Options{ return cors.Handler(cors.Options{
AllowedOrigins: origins, AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"}, ExposedHeaders: []string{"Link"},
AllowCredentials: true, AllowCredentials: false,
MaxAge: corsMaxAge, MaxAge: corsMaxAge,
}) })
} }
// parseCORSOrigins splits a comma-separated origin string into a slice,
// trimming whitespace. Returns nil if the input is empty.
func parseCORSOrigins(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
origins := make([]string, 0, len(parts))
for _, p := range parts {
if o := strings.TrimSpace(p); o != "" {
origins = append(origins, o)
}
}
return origins
}
// MetricsAuth returns basic auth middleware for metrics endpoint. // MetricsAuth returns basic auth middleware for metrics endpoint.
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler { func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
if m.params.Config.MetricsUsername == "" { if m.params.Config.MetricsUsername == "" {
@@ -370,27 +346,74 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
} }
} }
// APISessionAuth returns middleware that requires session authentication for API routes. // APITokenAuth returns middleware that authenticates requests via Bearer token.
// Unlike SessionAuth, it returns JSON 401 responses instead of redirecting to /login. // It looks up the token hash in the database and stores the user in context.
func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler { func (m *Middleware) APITokenAuth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func( return http.HandlerFunc(func(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
user, err := m.params.Auth.GetCurrentUser(request.Context(), request) authHeader := request.Header.Get("Authorization")
if err != nil || user == nil { if authHeader == "" {
writer.Header().Set("Content-Type", "application/json") http.Error(writer, `{"error":"missing Authorization header"}`, http.StatusUnauthorized)
http.Error(writer, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return return
} }
next.ServeHTTP(writer, request) const bearerPrefix = "Bearer "
if !strings.HasPrefix(authHeader, bearerPrefix) {
http.Error(writer, `{"error":"invalid Authorization header"}`, http.StatusUnauthorized)
return
}
rawToken := strings.TrimPrefix(authHeader, bearerPrefix)
if rawToken == "" {
http.Error(writer, `{"error":"empty token"}`, http.StatusUnauthorized)
return
}
hash := models.HashAPIToken(rawToken)
apiToken, err := models.FindAPITokenByHash(request.Context(), m.params.Database, hash)
if err != nil {
m.log.Error("api token lookup error", "error", err)
http.Error(writer, `{"error":"internal server error"}`, http.StatusInternalServerError)
return
}
if apiToken == nil {
http.Error(writer, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
// Touch last used (best-effort, don't block on error)
_ = apiToken.TouchLastUsed(request.Context())
user, userErr := models.FindUser(request.Context(), m.params.Database, apiToken.UserID)
if userErr != nil || user == nil {
http.Error(writer, `{"error":"token user not found"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(request.Context(), apiUserContextKey{}, user)
next.ServeHTTP(writer, request.WithContext(ctx))
}) })
} }
} }
// APIUserFromContext extracts the authenticated API user from the context.
func APIUserFromContext(ctx context.Context) *models.User {
user, _ := ctx.Value(apiUserContextKey{}).(*models.User)
return user
}
// 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 {
@@ -411,14 +434,8 @@ func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
} }
if setupRequired { if setupRequired {
path := request.URL.Path // Allow access to setup page
if request.URL.Path == "/setup" {
// Allow access to setup page, health endpoint, static
// assets, and API routes even before setup is complete.
if path == "/setup" ||
path == "/health" ||
strings.HasPrefix(path, "/s/") ||
strings.HasPrefix(path, "/api/") {
next.ServeHTTP(writer, request) next.ServeHTTP(writer, request)
return return

View File

@@ -0,0 +1,187 @@
package models
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
"git.eeqj.de/sneak/upaas/internal/database"
)
// tokenBytes is the number of random bytes for a raw API token.
const tokenBytes = 32
// APIToken represents an API authentication token.
type APIToken struct {
db *database.Database
ID int64
UserID int64
Name string
TokenHash string
CreatedAt time.Time
LastUsedAt sql.NullTime
}
// NewAPIToken creates a new APIToken with a database reference.
func NewAPIToken(db *database.Database) *APIToken {
return &APIToken{db: db}
}
// GenerateAPIToken creates a new API token for a user, returning the raw token
// string (shown once) and the persisted APIToken record.
func GenerateAPIToken(
ctx context.Context,
db *database.Database,
userID int64,
name string,
) (string, *APIToken, error) {
raw := make([]byte, tokenBytes)
_, err := rand.Read(raw)
if err != nil {
return "", nil, fmt.Errorf("generating token bytes: %w", err)
}
rawHex := hex.EncodeToString(raw)
hash := HashAPIToken(rawHex)
token := NewAPIToken(db)
token.UserID = userID
token.Name = name
token.TokenHash = hash
query := `INSERT INTO api_tokens (user_id, name, token_hash) VALUES (?, ?, ?)`
result, execErr := db.Exec(ctx, query, userID, name, hash)
if execErr != nil {
return "", nil, fmt.Errorf("inserting api token: %w", execErr)
}
id, idErr := result.LastInsertId()
if idErr != nil {
return "", nil, fmt.Errorf("getting token id: %w", idErr)
}
token.ID = id
reloadErr := token.Reload(ctx)
if reloadErr != nil {
return "", nil, fmt.Errorf("reloading token: %w", reloadErr)
}
return rawHex, token, nil
}
// HashAPIToken returns the SHA-256 hex digest of a raw token string.
func HashAPIToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// Reload refreshes the token from the database.
func (t *APIToken) Reload(ctx context.Context) error {
row := t.db.QueryRow(ctx,
`SELECT id, user_id, name, token_hash, created_at, last_used_at
FROM api_tokens WHERE id = ?`, t.ID,
)
return t.scan(row)
}
// Delete removes the token from the database.
func (t *APIToken) Delete(ctx context.Context) error {
_, err := t.db.Exec(ctx, "DELETE FROM api_tokens WHERE id = ?", t.ID)
return err
}
// TouchLastUsed updates the last_used_at timestamp.
func (t *APIToken) TouchLastUsed(ctx context.Context) error {
_, err := t.db.Exec(ctx,
"UPDATE api_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
t.ID,
)
return err
}
func (t *APIToken) scan(row *sql.Row) error {
return row.Scan(
&t.ID, &t.UserID, &t.Name, &t.TokenHash,
&t.CreatedAt, &t.LastUsedAt,
)
}
// FindAPITokenByHash looks up a token by its SHA-256 hash.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindAPITokenByHash(
ctx context.Context,
db *database.Database,
hash string,
) (*APIToken, error) {
token := NewAPIToken(db)
row := db.QueryRow(ctx,
`SELECT id, user_id, name, token_hash, created_at, last_used_at
FROM api_tokens WHERE token_hash = ?`, hash,
)
err := token.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning api token: %w", err)
}
return token, nil
}
// FindAPITokensByUserID returns all tokens for a user.
func FindAPITokensByUserID(
ctx context.Context,
db *database.Database,
userID int64,
) ([]*APIToken, error) {
rows, err := db.Query(ctx,
`SELECT id, user_id, name, token_hash, created_at, last_used_at
FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC`, userID,
)
if err != nil {
return nil, fmt.Errorf("querying api tokens: %w", err)
}
defer func() { _ = rows.Close() }()
var tokens []*APIToken
for rows.Next() {
tok := NewAPIToken(db)
scanErr := rows.Scan(
&tok.ID, &tok.UserID, &tok.Name, &tok.TokenHash,
&tok.CreatedAt, &tok.LastUsedAt,
)
if scanErr != nil {
return nil, fmt.Errorf("scanning api token row: %w", scanErr)
}
tokens = append(tokens, tok)
}
rowsErr := rows.Err()
if rowsErr != nil {
return nil, fmt.Errorf("iterating api token rows: %w", rowsErr)
}
return tokens, nil
}

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

@@ -5,7 +5,6 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"git.eeqj.de/sneak/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
@@ -77,11 +76,7 @@ func (d *Deployment) Reload(ctx context.Context) error {
return d.scan(row) return d.scan(row)
} }
// maxLogSize is the maximum size of deployment logs stored in the database (1MB).
const maxLogSize = 1 << 20
// AppendLog appends a log line to the deployment logs. // AppendLog appends a log line to the deployment logs.
// If the total log size exceeds maxLogSize, the oldest lines are truncated.
func (d *Deployment) AppendLog(ctx context.Context, line string) error { func (d *Deployment) AppendLog(ctx context.Context, line string) error {
var currentLogs string var currentLogs string
@@ -89,22 +84,7 @@ func (d *Deployment) AppendLog(ctx context.Context, line string) error {
currentLogs = d.Logs.String currentLogs = d.Logs.String
} }
newLogs := currentLogs + line + "\n" d.Logs = sql.NullString{String: currentLogs + line + "\n", Valid: true}
if len(newLogs) > maxLogSize {
// Keep the most recent logs that fit within the limit.
// Find a newline after the truncation point to avoid partial lines.
truncateAt := len(newLogs) - maxLogSize
idx := strings.Index(newLogs[truncateAt:], "\n")
if idx >= 0 {
newLogs = "[earlier logs truncated]\n" + newLogs[truncateAt+idx+1:]
} else {
newLogs = "[earlier logs truncated]\n" + newLogs[truncateAt:]
}
}
d.Logs = sql.NullString{String: newLogs, Valid: true}
return d.Save(ctx) return d.Save(ctx)
} }

View File

@@ -76,24 +76,20 @@ 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())
// 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}/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}/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}/delete", s.handlers.HandleVolumeDelete()) r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete())
// Ports // Ports
@@ -102,22 +98,20 @@ func (s *Server) SetupRoutes() {
}) })
}) })
// API v1 routes (cookie-based session auth, no CSRF) // API v1 routes (Bearer token auth, no CSRF)
s.router.Route("/api/v1", func(r chi.Router) { s.router.Route("/api/v1", func(r chi.Router) {
// Login endpoint is public (returns session cookie) r.Use(s.mw.APITokenAuth())
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("/whoami", s.handlers.HandleAPIWhoAmI())
r.Post("/tokens", s.handlers.HandleAPICreateToken())
r.Get("/apps", s.handlers.HandleAPIListApps()) r.Get("/apps", s.handlers.HandleAPIListApps())
r.Post("/apps", s.handlers.HandleAPICreateApp())
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp()) 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()) 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 != "" {

View File

@@ -11,7 +11,6 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"time" "time"
@@ -50,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.
@@ -251,8 +248,8 @@ func New(lc fx.Lifecycle, params ServiceParams) (*Service, error) {
} }
// GetBuildDir returns the build directory path for an app. // GetBuildDir returns the build directory path for an app.
func (svc *Service) GetBuildDir(appName string) string { func (svc *Service) GetBuildDir(appID string) string {
return filepath.Join(svc.config.DataDir, "builds", appName) return filepath.Join(svc.config.DataDir, "builds", appID)
} }
// GetLogFilePath returns the path to the log file for a deployment. // GetLogFilePath returns the path to the log file for a deployment.
@@ -362,105 +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, docker.ImageID(previousImageID))
if err != nil {
svc.failDeployment(bgCtx, app, deployment, err)
return fmt.Errorf("failed to build container options: %w", err)
}
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: string(containerID), Valid: true}
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+string(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,
@@ -471,7 +369,7 @@ func (svc *Service) runBuildAndDeploy(
// Build phase with timeout // Build phase with timeout
imageID, err := svc.buildImageWithTimeout(deployCtx, app, deployment) imageID, err := svc.buildImageWithTimeout(deployCtx, app, deployment)
if err != nil { if err != nil {
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, "") cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment)
if cancelErr != nil { if cancelErr != nil {
return cancelErr return cancelErr
} }
@@ -484,7 +382,7 @@ func (svc *Service) runBuildAndDeploy(
// Deploy phase with timeout // Deploy phase with timeout
err = svc.deployContainerWithTimeout(deployCtx, app, deployment, imageID) err = svc.deployContainerWithTimeout(deployCtx, app, deployment, imageID)
if err != nil { if err != nil {
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, imageID) cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment)
if cancelErr != nil { if cancelErr != nil {
return cancelErr return cancelErr
} }
@@ -492,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
@@ -514,7 +407,7 @@ func (svc *Service) buildImageWithTimeout(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
deployment *models.Deployment, deployment *models.Deployment,
) (docker.ImageID, error) { ) (string, error) {
buildCtx, cancel := context.WithTimeout(ctx, buildTimeout) buildCtx, cancel := context.WithTimeout(ctx, buildTimeout)
defer cancel() defer cancel()
@@ -539,7 +432,7 @@ func (svc *Service) deployContainerWithTimeout(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
deployment *models.Deployment, deployment *models.Deployment,
imageID docker.ImageID, imageID string,
) error { ) error {
deployCtx, cancel := context.WithTimeout(ctx, deployTimeout) deployCtx, cancel := context.WithTimeout(ctx, deployTimeout)
defer cancel() defer cancel()
@@ -660,77 +553,24 @@ func (svc *Service) cancelActiveDeploy(appID string) {
} }
// checkCancelled checks if the deploy context was cancelled (by a newer deploy) // checkCancelled checks if the deploy context was cancelled (by a newer deploy)
// and if so, marks the deployment as cancelled and cleans up orphan resources. // and if so, marks the deployment as cancelled. Returns ErrDeployCancelled or nil.
// Returns ErrDeployCancelled or nil.
func (svc *Service) checkCancelled( func (svc *Service) checkCancelled(
deployCtx context.Context, deployCtx context.Context,
bgCtx context.Context, bgCtx context.Context,
app *models.App, app *models.App,
deployment *models.Deployment, deployment *models.Deployment,
imageID docker.ImageID,
) error { ) error {
if !errors.Is(deployCtx.Err(), context.Canceled) { if !errors.Is(deployCtx.Err(), context.Canceled) {
return nil return nil
} }
svc.log.Info("deployment cancelled", "app", app.Name) svc.log.Info("deployment cancelled by newer deploy", "app", app.Name)
svc.cleanupCancelledDeploy(bgCtx, app, deployment, imageID)
_ = deployment.MarkFinished(bgCtx, models.DeploymentStatusCancelled) _ = deployment.MarkFinished(bgCtx, models.DeploymentStatusCancelled)
return ErrDeployCancelled return ErrDeployCancelled
} }
// cleanupCancelledDeploy removes orphan resources left by a cancelled deployment.
func (svc *Service) cleanupCancelledDeploy(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
imageID docker.ImageID,
) {
// Clean up the intermediate Docker image if one was built
if imageID != "" {
removeErr := svc.docker.RemoveImage(ctx, imageID)
if removeErr != nil {
svc.log.Error("failed to remove image from cancelled deploy",
"error", removeErr, "app", app.Name, "image", imageID)
_ = deployment.AppendLog(ctx, "WARNING: failed to clean up image "+string(imageID)+": "+removeErr.Error())
} else {
svc.log.Info("cleaned up image from cancelled deploy",
"app", app.Name, "image", imageID)
_ = deployment.AppendLog(ctx, "Cleaned up intermediate image: "+string(imageID))
}
}
// Clean up the build directory for this deployment
buildDir := svc.GetBuildDir(app.Name)
entries, err := os.ReadDir(buildDir)
if err != nil {
return
}
prefix := fmt.Sprintf("%d-", deployment.ID)
for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) {
dirPath := filepath.Join(buildDir, entry.Name())
removeErr := os.RemoveAll(dirPath)
if removeErr != nil {
svc.log.Error("failed to remove build dir from cancelled deploy",
"error", removeErr, "path", dirPath)
} else {
svc.log.Info("cleaned up build dir from cancelled deploy",
"app", app.Name, "path", dirPath)
_ = deployment.AppendLog(ctx, "Cleaned up build directory")
}
}
}
}
func (svc *Service) fetchWebhookEvent( func (svc *Service) fetchWebhookEvent(
ctx context.Context, ctx context.Context,
webhookEventID *int64, webhookEventID *int64,
@@ -816,7 +656,7 @@ func (svc *Service) buildImage(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
deployment *models.Deployment, deployment *models.Deployment,
) (docker.ImageID, error) { ) (string, error) {
workDir, cleanup, err := svc.cloneRepository(ctx, app, deployment) workDir, cleanup, err := svc.cloneRepository(ctx, app, deployment)
if err != nil { if err != nil {
return "", err return "", err
@@ -850,8 +690,8 @@ func (svc *Service) buildImage(
return "", fmt.Errorf("failed to build image: %w", err) return "", fmt.Errorf("failed to build image: %w", err)
} }
deployment.ImageID = sql.NullString{String: string(imageID), Valid: true} deployment.ImageID = sql.NullString{String: imageID, Valid: true}
_ = deployment.AppendLog(ctx, "Image built: "+string(imageID)) _ = deployment.AppendLog(ctx, "Image built: "+imageID)
return imageID, nil return imageID, nil
} }
@@ -1009,16 +849,16 @@ func (svc *Service) removeOldContainer(
svc.log.Warn("failed to remove old container", "error", removeErr) svc.log.Warn("failed to remove old container", "error", removeErr)
} }
_ = deployment.AppendLog(ctx, "Old container removed: "+string(containerInfo.ID[:12])) _ = deployment.AppendLog(ctx, "Old container removed: "+containerInfo.ID[:12])
} }
func (svc *Service) createAndStartContainer( func (svc *Service) createAndStartContainer(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
deployment *models.Deployment, deployment *models.Deployment,
imageID docker.ImageID, _ string,
) (docker.ContainerID, error) { ) (string, error) {
containerOpts, err := svc.buildContainerOptions(ctx, app, imageID) containerOpts, err := svc.buildContainerOptions(ctx, app, deployment.ID)
if err != nil { if err != nil {
svc.failDeployment(ctx, app, deployment, err) svc.failDeployment(ctx, app, deployment, err)
@@ -1038,8 +878,8 @@ func (svc *Service) createAndStartContainer(
return "", fmt.Errorf("failed to create container: %w", err) return "", fmt.Errorf("failed to create container: %w", err)
} }
deployment.ContainerID = sql.NullString{String: string(containerID), Valid: true} deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
_ = deployment.AppendLog(ctx, "Container created: "+string(containerID)) _ = deployment.AppendLog(ctx, "Container created: "+containerID)
startErr := svc.docker.StartContainer(ctx, containerID) startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil { if startErr != nil {
@@ -1062,7 +902,7 @@ func (svc *Service) createAndStartContainer(
func (svc *Service) buildContainerOptions( func (svc *Service) buildContainerOptions(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
imageID docker.ImageID, deploymentID int64,
) (docker.CreateContainerOptions, error) { ) (docker.CreateContainerOptions, error) {
envVars, err := app.GetEnvVars(ctx) envVars, err := app.GetEnvVars(ctx)
if err != nil { if err != nil {
@@ -1096,7 +936,7 @@ func (svc *Service) buildContainerOptions(
return docker.CreateContainerOptions{ return docker.CreateContainerOptions{
Name: "upaas-" + app.Name, Name: "upaas-" + app.Name,
Image: string(imageID), Image: fmt.Sprintf("upaas-%s:%d", app.Name, deploymentID),
Env: envMap, Env: envMap,
Labels: buildLabelMap(app, labels), Labels: buildLabelMap(app, labels),
Volumes: buildVolumeMounts(volumes), Volumes: buildVolumeMounts(volumes),
@@ -1146,9 +986,9 @@ func buildPortMappings(ports []*models.Port) []docker.PortMapping {
func (svc *Service) updateAppRunning( func (svc *Service) updateAppRunning(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
imageID docker.ImageID, imageID string,
) error { ) error {
app.ImageID = sql.NullString{String: string(imageID), Valid: true} app.ImageID = sql.NullString{String: imageID, Valid: true}
app.Status = models.AppStatusRunning app.Status = models.AppStatusRunning
saveErr := app.Save(ctx) saveErr := app.Save(ctx)

View File

@@ -1,63 +0,0 @@
package deploy_test
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
)
func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
cfg := &config.Config{DataDir: tmpDir}
svc := deploy.NewTestServiceWithConfig(slog.Default(), cfg, nil)
// Create a fake build directory matching the deployment pattern
appName := "test-app"
buildDir := svc.GetBuildDirExported(appName)
require.NoError(t, os.MkdirAll(buildDir, 0o750))
// Create deployment-specific dir: <deploymentID>-<random>
deployDir := filepath.Join(buildDir, "42-abc123")
require.NoError(t, os.MkdirAll(deployDir, 0o750))
// Create a file inside to verify full removal
require.NoError(t, os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o600))
// Also create a dir for a different deployment (should NOT be removed)
otherDir := filepath.Join(buildDir, "99-xyz789")
require.NoError(t, os.MkdirAll(otherDir, 0o750))
// Run cleanup for deployment 42
svc.CleanupCancelledDeploy(context.Background(), appName, 42, "")
// Deployment 42's dir should be gone
_, err := os.Stat(deployDir)
assert.True(t, os.IsNotExist(err), "deployment build dir should be removed")
// Deployment 99's dir should still exist
_, err = os.Stat(otherDir)
assert.NoError(t, err, "other deployment build dir should not be removed")
}
func TestCleanupCancelledDeploy_NoBuildDir(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
cfg := &config.Config{DataDir: tmpDir}
svc := deploy.NewTestServiceWithConfig(slog.Default(), cfg, nil)
// Should not panic when build dir doesn't exist
svc.CleanupCancelledDeploy(context.Background(), "nonexistent-app", 1, "")
}

View File

@@ -2,14 +2,7 @@ package deploy
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"os"
"path/filepath"
"strings"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/docker"
) )
// NewTestService creates a Service with minimal dependencies for testing. // NewTestService creates a Service with minimal dependencies for testing.
@@ -38,45 +31,3 @@ func (svc *Service) TryLockApp(appID string) bool {
func (svc *Service) UnlockApp(appID string) { func (svc *Service) UnlockApp(appID string) {
svc.unlockApp(appID) svc.unlockApp(appID)
} }
// NewTestServiceWithConfig creates a Service with config and docker client for testing.
func NewTestServiceWithConfig(log *slog.Logger, cfg *config.Config, dockerClient *docker.Client) *Service {
return &Service{
log: log,
config: cfg,
docker: dockerClient,
}
}
// CleanupCancelledDeploy exposes the build directory cleanup portion of
// cleanupCancelledDeploy for testing. It removes build directories matching
// the deployment ID prefix.
func (svc *Service) CleanupCancelledDeploy(
_ context.Context,
appName string,
deploymentID int64,
_ string,
) {
// We can't create real models.App/Deployment in tests easily,
// so we test the build dir cleanup portion directly.
buildDir := svc.GetBuildDir(appName)
entries, err := os.ReadDir(buildDir)
if err != nil {
return
}
prefix := fmt.Sprintf("%d-", deploymentID)
for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) {
dirPath := filepath.Join(buildDir, entry.Name())
_ = os.RemoveAll(dirPath)
}
}
}
// GetBuildDirExported exposes GetBuildDir for testing.
func (svc *Service) GetBuildDirExported(appName string) string {
return svc.GetBuildDir(appName)
}

View File

@@ -10,7 +10,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
@@ -248,15 +247,10 @@ func (svc *Service) sendNtfy(
) error { ) error {
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title) svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
parsedURL, err := url.ParseRequestURI(topic)
if err != nil {
return fmt.Errorf("invalid ntfy topic URL: %w", err)
}
request, err := http.NewRequestWithContext( request, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
parsedURL.String(), topic,
bytes.NewBufferString(message), bytes.NewBufferString(message),
) )
if err != nil { if err != nil {
@@ -266,7 +260,7 @@ func (svc *Service) sendNtfy(
request.Header.Set("Title", title) request.Header.Set("Title", title)
request.Header.Set("Priority", svc.ntfyPriority(priority)) request.Header.Set("Priority", svc.ntfyPriority(priority))
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input resp, err := svc.client.Do(request)
if err != nil { if err != nil {
return fmt.Errorf("failed to send ntfy request: %w", err) return fmt.Errorf("failed to send ntfy request: %w", err)
} }
@@ -346,15 +340,10 @@ func (svc *Service) sendSlack(
return fmt.Errorf("failed to marshal slack payload: %w", err) return fmt.Errorf("failed to marshal slack payload: %w", err)
} }
parsedWebhookURL, err := url.ParseRequestURI(webhookURL)
if err != nil {
return fmt.Errorf("invalid slack webhook URL: %w", err)
}
request, err := http.NewRequestWithContext( request, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
parsedWebhookURL.String(), webhookURL,
bytes.NewBuffer(body), bytes.NewBuffer(body),
) )
if err != nil { if err != nil {
@@ -363,7 +352,7 @@ func (svc *Service) sendSlack(
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input resp, err := svc.client.Do(request)
if err != nil { if err != nil {
return fmt.Errorf("failed to send slack request: %w", err) return fmt.Errorf("failed to send slack request: %w", err)
} }

View File

@@ -1,7 +0,0 @@
package webhook
// UnparsedURL is a URL stored as a plain string without parsing.
// Use this instead of string when the value is known to be a URL
// but should not be parsed into a net/url.URL (e.g. webhook URLs,
// compare URLs from external payloads).
type UnparsedURL string

View File

@@ -50,12 +50,12 @@ type GiteaPushPayload struct {
Ref string `json:"ref"` Ref string `json:"ref"`
Before string `json:"before"` Before string `json:"before"`
After string `json:"after"` After string `json:"after"`
CompareURL UnparsedURL `json:"compare_url"` CompareURL string `json:"compare_url"`
Repository struct { Repository struct {
FullName string `json:"full_name"` FullName string `json:"full_name"`
CloneURL UnparsedURL `json:"clone_url"` CloneURL string `json:"clone_url"`
SSHURL string `json:"ssh_url"` SSHURL string `json:"ssh_url"`
HTMLURL UnparsedURL `json:"html_url"` HTMLURL string `json:"html_url"`
} `json:"repository"` } `json:"repository"`
Pusher struct { Pusher struct {
Username string `json:"username"` Username string `json:"username"`
@@ -63,7 +63,7 @@ type GiteaPushPayload struct {
} `json:"pusher"` } `json:"pusher"`
Commits []struct { Commits []struct {
ID string `json:"id"` ID string `json:"id"`
URL UnparsedURL `json:"url"` URL string `json:"url"`
Message string `json:"message"` Message string `json:"message"`
Author struct { Author struct {
Name string `json:"name"` Name string `json:"name"`
@@ -104,7 +104,7 @@ func (svc *Service) HandleWebhook(
event.EventType = eventType event.EventType = eventType
event.Branch = branch event.Branch = branch
event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""} event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""}
event.CommitURL = sql.NullString{String: string(commitURL), Valid: commitURL != ""} event.CommitURL = sql.NullString{String: commitURL, Valid: commitURL != ""}
event.Payload = sql.NullString{String: string(payload), Valid: true} event.Payload = sql.NullString{String: string(payload), Valid: true}
event.Matched = matched event.Matched = matched
event.Processed = false event.Processed = false
@@ -168,7 +168,7 @@ func extractBranch(ref string) string {
// extractCommitURL extracts the commit URL from the webhook payload. // extractCommitURL extracts the commit URL from the webhook payload.
// Prefers the URL from the head commit, falls back to constructing from repo URL. // Prefers the URL from the head commit, falls back to constructing from repo URL.
func extractCommitURL(payload GiteaPushPayload) UnparsedURL { func extractCommitURL(payload GiteaPushPayload) string {
// Try to find the URL from the head commit (matching After SHA) // Try to find the URL from the head commit (matching After SHA)
for _, commit := range payload.Commits { for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" { if commit.ID == payload.After && commit.URL != "" {
@@ -178,7 +178,7 @@ func extractCommitURL(payload GiteaPushPayload) UnparsedURL {
// Fall back to constructing URL from repo HTML URL // Fall back to constructing URL from repo HTML URL
if payload.Repository.HTMLURL != "" && payload.After != "" { if payload.Repository.HTMLURL != "" && payload.After != "" {
return UnparsedURL(string(payload.Repository.HTMLURL) + "/commit/" + payload.After) return payload.Repository.HTMLURL + "/commit/" + payload.After
} }
return "" return ""

View File

@@ -12,7 +12,7 @@ import (
// KeyPair contains an SSH key pair. // KeyPair contains an SSH key pair.
type KeyPair struct { type KeyPair struct {
PrivateKey string `json:"-"` PrivateKey string
PublicKey string PublicKey string
} }

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

3047
static/js/alpine.min.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -22,9 +22,7 @@ document.addEventListener("alpine:init", () => {
if (diffSec < 60) return "just now"; if (diffSec < 60) return "just now";
if (diffMin < 60) if (diffMin < 60)
return ( return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago");
diffMin + (diffMin === 1 ? " minute ago" : " minutes ago")
);
if (diffHour < 24) if (diffHour < 24)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago"); return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffDay < 7) if (diffDay < 7)
@@ -36,8 +34,7 @@ document.addEventListener("alpine:init", () => {
* Get the badge class for a given status * Get the badge class for a given status
*/ */
statusBadgeClass(status) { statusBadgeClass(status) {
if (status === "running" || status === "success") if (status === "running" || status === "success") return "badge-success";
return "badge-success";
if (status === "building" || status === "deploying") if (status === "building" || status === "deploying")
return "badge-warning"; return "badge-warning";
if (status === "failed" || status === "error") return "badge-error"; if (status === "failed" || status === "error") return "badge-error";
@@ -76,9 +73,7 @@ document.addEventListener("alpine:init", () => {
*/ */
isScrolledToBottom(el, tolerance = 30) { isScrolledToBottom(el, tolerance = 30) {
if (!el) return true; if (!el) return true;
return ( return el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance;
el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance
);
}, },
/** /**
@@ -199,22 +194,14 @@ document.addEventListener("alpine:init", () => {
// Set up scroll listeners after DOM is ready // Set up scroll listeners after DOM is ready
this.$nextTick(() => { this.$nextTick(() => {
this._initScrollTracking( this._initScrollTracking(this.$refs.containerLogsWrapper, '_containerAutoScroll');
this.$refs.containerLogsWrapper, this._initScrollTracking(this.$refs.buildLogsWrapper, '_buildAutoScroll');
"_containerAutoScroll",
);
this._initScrollTracking(
this.$refs.buildLogsWrapper,
"_buildAutoScroll",
);
}); });
}, },
_schedulePoll() { _schedulePoll() {
if (this._pollTimer) clearTimeout(this._pollTimer); if (this._pollTimer) clearTimeout(this._pollTimer);
const interval = Alpine.store("utils").isDeploying(this.appStatus) const interval = Alpine.store("utils").isDeploying(this.appStatus) ? 1000 : 10000;
? 1000
: 10000;
this._pollTimer = setTimeout(() => { this._pollTimer = setTimeout(() => {
this.fetchAll(); this.fetchAll();
this._schedulePoll(); this._schedulePoll();
@@ -223,29 +210,18 @@ document.addEventListener("alpine:init", () => {
_initScrollTracking(el, flag) { _initScrollTracking(el, flag) {
if (!el) return; if (!el) return;
el.addEventListener( el.addEventListener('scroll', () => {
"scroll",
() => {
this[flag] = Alpine.store("utils").isScrolledToBottom(el); this[flag] = Alpine.store("utils").isScrolledToBottom(el);
}, }, { passive: true });
{ passive: true },
);
}, },
fetchAll() { fetchAll() {
this.fetchAppStatus(); this.fetchAppStatus();
// Only fetch logs when the respective pane is visible // Only fetch logs when the respective pane is visible
if ( if (this.$refs.containerLogsWrapper && this._isElementVisible(this.$refs.containerLogsWrapper)) {
this.$refs.containerLogsWrapper &&
this._isElementVisible(this.$refs.containerLogsWrapper)
) {
this.fetchContainerLogs(); this.fetchContainerLogs();
} }
if ( if (this.showBuildLogs && this.$refs.buildLogsWrapper && this._isElementVisible(this.$refs.buildLogsWrapper)) {
this.showBuildLogs &&
this.$refs.buildLogsWrapper &&
this._isElementVisible(this.$refs.buildLogsWrapper)
) {
this.fetchBuildLogs(); this.fetchBuildLogs();
} }
this.fetchRecentDeployments(); this.fetchRecentDeployments();
@@ -294,9 +270,7 @@ document.addEventListener("alpine:init", () => {
this.containerStatus = data.status; this.containerStatus = data.status;
if (changed && this._containerAutoScroll) { if (changed && this._containerAutoScroll) {
this.$nextTick(() => { this.$nextTick(() => {
Alpine.store("utils").scrollToBottom( Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
this.$refs.containerLogsWrapper,
);
}); });
} }
} catch (err) { } catch (err) {
@@ -317,9 +291,7 @@ document.addEventListener("alpine:init", () => {
this.buildStatus = data.status; this.buildStatus = data.status;
if (changed && this._buildAutoScroll) { if (changed && this._buildAutoScroll) {
this.$nextTick(() => { this.$nextTick(() => {
Alpine.store("utils").scrollToBottom( Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
this.$refs.buildLogsWrapper,
);
}); });
} }
} catch (err) { } catch (err) {
@@ -329,9 +301,7 @@ document.addEventListener("alpine:init", () => {
async fetchRecentDeployments() { async fetchRecentDeployments() {
try { try {
const res = await fetch( const res = await fetch(`/apps/${this.appId}/recent-deployments`);
`/apps/${this.appId}/recent-deployments`,
);
const data = await res.json(); const data = await res.json();
this.deployments = data.deployments || []; this.deployments = data.deployments || [];
} catch (err) { } catch (err) {
@@ -364,8 +334,7 @@ document.addEventListener("alpine:init", () => {
get buildStatusBadgeClass() { get buildStatusBadgeClass() {
return ( return (
Alpine.store("utils").statusBadgeClass(this.buildStatus) + Alpine.store("utils").statusBadgeClass(this.buildStatus) + " text-xs"
" text-xs"
); );
}, },
@@ -400,22 +369,15 @@ document.addEventListener("alpine:init", () => {
init() { init() {
// Read initial logs from script tag (avoids escaping issues) // Read initial logs from script tag (avoids escaping issues)
const initialLogsEl = this.$el.querySelector(".initial-logs"); const initialLogsEl = this.$el.querySelector(".initial-logs");
this.logs = initialLogsEl?.dataset.logs || "Loading..."; this.logs = initialLogsEl?.textContent || "Loading...";
// Set up scroll tracking // Set up scroll tracking
this.$nextTick(() => { this.$nextTick(() => {
const wrapper = this.$refs.logsWrapper; const wrapper = this.$refs.logsWrapper;
if (wrapper) { if (wrapper) {
wrapper.addEventListener( wrapper.addEventListener('scroll', () => {
"scroll", this._autoScroll = Alpine.store("utils").isScrolledToBottom(wrapper);
() => { }, { passive: true });
this._autoScroll =
Alpine.store("utils").isScrolledToBottom(
wrapper,
);
},
{ passive: true },
);
} }
}); });
@@ -446,9 +408,7 @@ document.addEventListener("alpine:init", () => {
// Scroll to bottom only when content changes AND user hasn't scrolled up // Scroll to bottom only when content changes AND user hasn't scrolled up
if (logsChanged && this._autoScroll) { if (logsChanged && this._autoScroll) {
this.$nextTick(() => { this.$nextTick(() => {
Alpine.store("utils").scrollToBottom( Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper);
this.$refs.logsWrapper,
);
}); });
} }
@@ -573,8 +533,7 @@ document.addEventListener("alpine:init", () => {
this.$el.querySelectorAll("[data-time]").forEach((el) => { this.$el.querySelectorAll("[data-time]").forEach((el) => {
const time = el.getAttribute("data-time"); const time = el.getAttribute("data-time");
if (time) { if (time) {
el.textContent = el.textContent = Alpine.store("utils").formatRelativeTime(time);
Alpine.store("utils").formatRelativeTime(time);
} }
}); });
}, 60000); }, 60000);

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>
@@ -112,34 +106,15 @@
</thead> </thead>
<tbody class="table-body"> <tbody class="table-body">
{{range .EnvVars}} {{range .EnvVars}}
<tr x-data="{ editing: false }"> <tr>
<template x-if="!editing">
<td class="font-mono font-medium">{{.Key}}</td> <td class="font-mono font-medium">{{.Key}}</td>
</template>
<template x-if="!editing">
<td class="font-mono text-gray-500">{{.Value}}</td> <td class="font-mono text-gray-500">{{.Value}}</td>
</template>
<template x-if="!editing">
<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> <form method="POST" action="/apps/{{$.App.ID}}/env/{{.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 x-if="editing">
<td colspan="3">
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/edit" class="flex gap-2 items-center">
{{ $.CSRFField }}
<input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form>
<p class="text-xs text-amber-600 mt-1">⚠ Container restart needed after env var changes.</p>
</td>
</template>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
@@ -176,33 +151,15 @@
</td> </td>
</tr> </tr>
{{range .Labels}} {{range .Labels}}
<tr x-data="{ editing: false }"> <tr>
<template x-if="!editing">
<td class="font-mono font-medium">{{.Key}}</td> <td class="font-mono font-medium">{{.Key}}</td>
</template>
<template x-if="!editing">
<td class="font-mono text-gray-500">{{.Value}}</td> <td class="font-mono text-gray-500">{{.Value}}</td>
</template>
<template x-if="!editing">
<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>
<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 x-if="editing">
<td colspan="3">
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/edit" class="flex gap-2 items-center">
{{ $.CSRFField }}
<input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form>
</td>
</template>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
@@ -232,14 +189,9 @@
</thead> </thead>
<tbody class="table-body"> <tbody class="table-body">
{{range .Volumes}} {{range .Volumes}}
<tr x-data="{ editing: false }"> <tr>
<template x-if="!editing">
<td class="font-mono">{{.HostPath}}</td> <td class="font-mono">{{.HostPath}}</td>
</template>
<template x-if="!editing">
<td class="font-mono">{{.ContainerPath}}</td> <td class="font-mono">{{.ContainerPath}}</td>
</template>
<template x-if="!editing">
<td> <td>
{{if .ReadOnly}} {{if .ReadOnly}}
<span class="badge-neutral">Read-only</span> <span class="badge-neutral">Read-only</span>
@@ -247,31 +199,12 @@
<span class="badge-info">Read-write</span> <span class="badge-info">Read-write</span>
{{end}} {{end}}
</td> </td>
</template>
<template x-if="!editing">
<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>
<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 x-if="editing">
<td colspan="4">
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/edit" class="flex gap-2 items-center">
{{ $.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="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">
<input type="checkbox" name="readonly" value="1" {{if .ReadOnly}}checked{{end}} class="rounded border-gray-300 text-primary-600 focus:ring-primary-500">
RO
</label>
<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>
</form>
</td>
</template>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>

View File

@@ -98,7 +98,7 @@
title="Scroll to bottom" title="Scroll to bottom"
>↓ Follow</button> >↓ Follow</button>
</div> </div>
{{if .Logs.Valid}}<div hidden class="initial-logs" data-logs="{{.Logs.String}}"></div>{{end}} {{if .Logs.Valid}}<script type="text/plain" class="initial-logs">{{.Logs.String}}</script>{{end}}
</div> </div>
{{end}} {{end}}
</div> </div>