1 Commits

Author SHA1 Message Date
4e6542badf Consolidate database schema into two files: init migrations table and complete schema
Since there is no existing installed base, we can consolidate all migrations into a single complete schema file plus the migrations table initialization. This simplifies the database setup for new installations.
2026-02-16 14:51:33 +01:00
53 changed files with 1300 additions and 4907 deletions

View File

@@ -1,16 +0,0 @@
name: Check
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4, 2024-10-13
- name: Build (runs make check inside Dockerfile)
run: docker build .

180
BUGS.md Normal file
View File

@@ -0,0 +1,180 @@
# Bugs in µPaaS
## 1. Potential Race Condition in Log Writing
### Description
In the deployment service, when a deployment fails, the `failDeployment` function calls `writeLogsToFile` which may be called concurrently with the async log writer's flush operations. This could lead to partial or corrupted log files.
### Location
`internal/service/deploy/deploy.go:1169` in `failDeployment` function
### Proposed Fix
1. Add synchronization to ensure only one log write operation occurs at a time
2. Modify the `deploymentLogWriter` to track completion status and prevent concurrent writes
3. Add a wait mechanism in `failDeployment` to ensure any ongoing flush operations complete before writing logs to file
```go
// Add a mutex to deploymentLogWriter
type deploymentLogWriter struct {
// existing fields...
mu sync.Mutex
writeMu sync.Mutex // Add this for file writing synchronization
done chan struct{}
flushed sync.WaitGroup
}
// In writeLogsToFile, ensure exclusive access
func (svc *Service) writeLogsToFile(app *models.App, deployment *models.Deployment) {
svc.writeMu.Lock() // Add this mutex to Service struct
defer svc.writeMu.Unlock()
// existing code...
}
```
## 2. Incomplete Error Handling in Container Operations
### Description
In the Docker client's `performClone` function, if `createGitContainer` fails, the SSH key file created earlier is not cleaned up, causing a potential security risk.
### Location
`internal/docker/client.go:597` in `performClone` function
### Proposed Fix
Add proper cleanup using `defer` immediately after creating the SSH key file:
```go
// After writing SSH key file (line 578)
keyFileCreated := false
err = os.WriteFile(cfg.keyFile, []byte(cfg.sshPrivateKey), sshKeyPermissions)
if err != nil {
return nil, fmt.Errorf("failed to write SSH key: %w", err)
}
keyFileCreated = true
defer func() {
if keyFileCreated {
removeErr := os.Remove(cfg.keyFile)
if removeErr != nil {
c.log.Error("failed to remove SSH key file", "error", removeErr)
}
}
}()
```
## 3. Missing Context Cancellation Check During Build
### Description
In the deployment service's `streamBuildOutput` function, long-running Docker build operations may not properly respond to context cancellation, causing deployments to hang even when cancelled.
### Location
`internal/docker/client.go:542` in `streamBuildOutput` function
### Proposed Fix
Add context checking in the scanner loop:
```go
for scanner.Scan() {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
line := scanner.Bytes()
// existing code...
}
```
## 4. Inconsistent Container Removal in Error Cases
### Description
When deployment fails during container creation, the already-created container is not removed, leading to orphaned containers that consume resources.
### Location
`internal/service/deploy/deploy.go:969` in `createAndStartContainer` function
### Proposed Fix
Add cleanup of created container on start failure:
```go
containerID, err := svc.docker.CreateContainer(ctx, containerOpts)
if err != nil {
svc.notify.NotifyDeployFailed(ctx, app, deployment, err)
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create container: %w", err))
return "", fmt.Errorf("failed to create container: %w", err)
}
// Add cleanup defer for error cases
defer func() {
if err != nil {
// If we have a container ID but returning an error, clean it up
_ = svc.docker.RemoveContainer(context.Background(), containerID, true)
}
}()
startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil {
svc.notify.NotifyDeployFailed(ctx, app, deployment, startErr)
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to start container: %w", startErr))
err = startErr // Set err so defer cleanup runs
return "", fmt.Errorf("failed to start container: %w", startErr)
}
```
## 5. Potential Data Race in Active Deployments Tracking
### Description
The `activeDeploys` sync.Map in the deployment service may have race conditions when multiple concurrent deployments try to access the same app's deployment state.
### Location
`internal/service/deploy/deploy.go:226` and related functions
### Proposed Fix
Add proper locking around active deploy operations:
```go
// Add a mutex for active deploy operations
type Service struct {
// existing fields...
activeDeployMu sync.Mutex
}
// In Deploy function
func (svc *Service) Deploy(ctx context.Context, app *models.App, webhookEventID *int64, cancelExisting bool) error {
svc.activeDeployMu.Lock()
if cancelExisting {
svc.cancelActiveDeploy(app.ID)
}
// Try to acquire per-app deployment lock
if !svc.tryLockApp(app.ID) {
svc.activeDeployMu.Unlock()
svc.log.Warn("deployment already in progress", "app", app.Name)
return ErrDeploymentInProgress
}
svc.activeDeployMu.Unlock()
defer svc.unlockApp(app.ID)
// rest of function...
}
```
## 6. Incomplete Error Propagation in Git Clone
### Description
In the Docker client's `runGitClone` function, if `ContainerLogs` fails, the error is silently ignored, which could hide important debugging information.
### Location
`internal/docker/client.go:679` in `runGitClone` function
### Proposed Fix
Handle the ContainerLogs error properly:
```go
// Always capture logs for the result
logs, logErr := c.ContainerLogs(ctx, containerID, "100")
if logErr != nil {
c.log.Warn("failed to get git clone logs", "error", logErr)
logs = "Failed to retrieve logs: " + logErr.Error()
}
```

68
CLAUDE.md Normal file
View File

@@ -0,0 +1,68 @@
# Repository Rules
Last Updated 2026-01-08
These rules MUST be followed at all times, it is very important.
* Never use `git add -A` - add specific changes to a deliberate commit. A
commit should contain one change. After each change, make a commit with a
good one-line summary.
* NEVER modify the linter config without asking first.
* NEVER modify tests to exclude special cases or otherwise get them to pass
without asking first. In almost all cases, the code should be changed,
NOT the tests. If you think the test needs to be changed, make your case
for that and ask for permission to proceed, then stop. You need explicit
user approval to modify existing tests. (You do not need user approval
for writing NEW tests.)
* When linting, assume the linter config is CORRECT, and that each item
output by the linter is something that legitimately needs fixing in the
code.
* When running tests, use `make test`.
* Before commits, run `make check`. This runs `make lint` and `make test`
and `make check-fmt`. Any issues discovered MUST be resolved before
committing unless explicitly told otherwise.
* When fixing a bug, write a failing test for the bug FIRST. Add
appropriate logging to the test to ensure it is written correctly. Commit
that. Then go about fixing the bug until the test passes (without
modifying the test further). Then commit that.
* When adding a new feature, do the same - implement a test first (TDD). It
doesn't have to be super complex. Commit the test, then commit the
feature.
* When adding a new feature, use a feature branch. When the feature is
completely finished and the code is up to standards (passes `make check`)
then and only then can the feature branch be merged into `main` and the
branch deleted.
* Write godoc documentation comments for all exported types and functions as
you go along.
* ALWAYS be consistent in naming. If you name something one thing in one
place, name it the EXACT SAME THING in another place.
* Be descriptive and specific in naming. `wl` is bad;
`SourceHostWhitelist` is good. `ConnsPerHost` is bad;
`MaxConnectionsPerHost` is good.
* This is not prototype or teaching code - this is designed for production.
Any security issues (such as denial of service) or other web
vulnerabilities are P1 bugs and must be added to TODO.md at the top.
* As this is production code, no stubbing of implementations unless
specifically instructed. We need working implementations.
* Avoid vendoring deps unless specifically instructed to. NEVER commit
the vendor directory, NEVER commit compiled binaries. If these
directories or files exist, add them to .gitignore (and commit the
.gitignore) if they are not already in there. Keep the entire git
repository (with history) small - under 20MiB, unless you specifically
must commit larger files (e.g. test fixture example media files). Only
OUR source code and immediately supporting files (such as test examples)
goes into the repo/history.

View File

@@ -1,37 +1,26 @@
# Lint stage — fast feedback on formatting and lint issues # Build stage
# golangci/golangci-lint:v2.10.1 FROM golang:1.25-alpine AS builder
FROM golangci/golangci-lint@sha256:ea84d14c2fef724411be7dc45e09e6ef721d748315252b02df19a7e3113ee763 AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage — tests and compilation
# golang:1.25-alpine
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
# Force BuildKit to run the lint stage by creating a stage dependency
COPY --from=lint /src/go.sum /dev/null
RUN apk add --no-cache git make gcc musl-dev RUN apk add --no-cache git make gcc musl-dev
# Install golangci-lint v2
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
RUN go install golang.org/x/tools/cmd/goimports@latest
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN make test # Run all checks - build fails if any check fails
RUN make check
# Build the binary
RUN make build RUN make build
# Runtime stage # Runtime stage
# alpine:3.19 FROM alpine:3.19
FROM alpine@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
RUN apk add --no-cache ca-certificates tzdata git openssh-client docker-cli RUN apk add --no-cache ca-certificates tzdata git openssh-client docker-cli

View File

@@ -1,4 +1,4 @@
.PHONY: all build lint fmt fmt-check test check clean .PHONY: all build lint fmt test check clean
BINARY := upaasd BINARY := upaasd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@@ -18,15 +18,20 @@ fmt:
goimports -w . goimports -w .
npx prettier --write --tab-width 4 static/js/*.js npx prettier --write --tab-width 4 static/js/*.js
fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
test: test:
go test -v -race -cover ./... go test -v -race -cover ./...
# Check runs all validation without making changes # Check runs all validation without making changes
# Used by CI and Docker build - fails if anything is wrong # Used by CI and Docker build - fails if anything is wrong
check: fmt-check lint test check:
@echo "==> Checking formatting..."
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
@echo "==> Running linter..."
golangci-lint run --config .golangci.yml ./...
@echo "==> Running tests..."
go test -v -race ./...
@echo "==> Building..."
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/upaasd
@echo "==> All checks passed!" @echo "==> All checks passed!"
clean: clean:

View File

@@ -157,8 +157,8 @@ Environment variables:
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| |----------|-------------|---------|
| `PORT` | HTTP listen port | 8080 | | `PORT` | HTTP listen port | 8080 |
| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | `./data` (local dev only — use absolute path for Docker) | | `UPAAS_DATA_DIR` | Data directory for SQLite and keys | ./data |
| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | *(none — must be set to an absolute path)* | | `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | same as DATA_DIR |
| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock | | `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock |
| `DEBUG` | Enable debug logging | false | | `DEBUG` | Enable debug logging | false |
| `SENTRY_DSN` | Sentry error reporting DSN | "" | | `SENTRY_DSN` | Sentry error reporting DSN | "" |
@@ -176,35 +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**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the host before running
`docker compose up`. This value is bind-mounted into the container and passed as `UPAAS_HOST_DATA_DIR`
so that Docker bind mounts during builds resolve correctly. Relative paths (e.g. `./data`) will break
container builds because the Docker daemon resolves paths relative to the host, not the container.
Example: `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`.

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:

2
go.mod
View File

@@ -19,7 +19,6 @@ require (
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
go.uber.org/fx v1.24.0 go.uber.org/fx v1.24.0
golang.org/x/crypto v0.46.0 golang.org/x/crypto v0.46.0
golang.org/x/time v0.12.0
) )
require ( require (
@@ -75,6 +74,7 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect gotest.tools/v3 v3.5.2 // indirect

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,6 @@
-- Initialize migrations table for tracking applied migrations
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1,7 +1,8 @@
-- Initial schema for upaas -- Complete schema for upaas (consolidated)
-- This represents the final state of all migrations applied
-- Users table (single admin user) -- Users table (single admin user)
CREATE TABLE users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
@@ -9,7 +10,7 @@ CREATE TABLE users (
); );
-- Apps table -- Apps table
CREATE TABLE apps ( CREATE TABLE IF NOT EXISTS apps (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL, name TEXT UNIQUE NOT NULL,
repo_url TEXT NOT NULL, repo_url TEXT NOT NULL,
@@ -18,18 +19,19 @@ CREATE TABLE apps (
webhook_secret TEXT NOT NULL, webhook_secret TEXT NOT NULL,
ssh_private_key TEXT NOT NULL, ssh_private_key TEXT NOT NULL,
ssh_public_key TEXT NOT NULL, ssh_public_key TEXT NOT NULL,
container_id TEXT,
image_id TEXT, image_id TEXT,
previous_image_id TEXT,
status TEXT DEFAULT 'pending', status TEXT DEFAULT 'pending',
docker_network TEXT, docker_network TEXT,
ntfy_topic TEXT, ntfy_topic TEXT,
slack_webhook TEXT, slack_webhook TEXT,
webhook_secret_hash TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
-- App environment variables -- App environment variables
CREATE TABLE app_env_vars ( CREATE TABLE IF NOT EXISTS app_env_vars (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
key TEXT NOT NULL, key TEXT NOT NULL,
@@ -38,7 +40,7 @@ CREATE TABLE app_env_vars (
); );
-- App labels -- App labels
CREATE TABLE app_labels ( CREATE TABLE IF NOT EXISTS app_labels (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
key TEXT NOT NULL, key TEXT NOT NULL,
@@ -47,7 +49,7 @@ CREATE TABLE app_labels (
); );
-- App volume mounts -- App volume mounts
CREATE TABLE app_volumes ( CREATE TABLE IF NOT EXISTS app_volumes (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
host_path TEXT NOT NULL, host_path TEXT NOT NULL,
@@ -55,13 +57,24 @@ CREATE TABLE app_volumes (
readonly INTEGER DEFAULT 0 readonly INTEGER DEFAULT 0
); );
-- App port mappings
CREATE TABLE IF NOT EXISTS app_ports (
id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
host_port INTEGER NOT NULL,
container_port INTEGER NOT NULL,
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK(protocol IN ('tcp', 'udp')),
UNIQUE(host_port, protocol)
);
-- Webhook events log -- Webhook events log
CREATE TABLE webhook_events ( CREATE TABLE IF NOT EXISTS webhook_events (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
event_type TEXT NOT NULL, event_type TEXT NOT NULL,
branch TEXT NOT NULL, branch TEXT NOT NULL,
commit_sha TEXT, commit_sha TEXT,
commit_url TEXT,
payload TEXT, payload TEXT,
matched INTEGER NOT NULL, matched INTEGER NOT NULL,
processed INTEGER DEFAULT 0, processed INTEGER DEFAULT 0,
@@ -69,13 +82,13 @@ CREATE TABLE webhook_events (
); );
-- Deployments log -- Deployments log
CREATE TABLE deployments ( CREATE TABLE IF NOT EXISTS deployments (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
webhook_event_id INTEGER REFERENCES webhook_events(id), webhook_event_id INTEGER REFERENCES webhook_events(id),
commit_sha TEXT, commit_sha TEXT,
commit_url TEXT,
image_id TEXT, image_id TEXT,
container_id TEXT,
status TEXT NOT NULL, status TEXT NOT NULL,
logs TEXT, logs TEXT,
started_at DATETIME DEFAULT CURRENT_TIMESTAMP, started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -83,12 +96,14 @@ CREATE TABLE deployments (
); );
-- Indexes -- Indexes
CREATE INDEX idx_apps_status ON apps(status); CREATE INDEX IF NOT EXISTS idx_apps_status ON apps(status);
CREATE INDEX idx_apps_webhook_secret ON apps(webhook_secret); CREATE INDEX IF NOT EXISTS idx_apps_webhook_secret ON apps(webhook_secret);
CREATE INDEX idx_app_env_vars_app_id ON app_env_vars(app_id); CREATE INDEX IF NOT EXISTS idx_apps_webhook_secret_hash ON apps(webhook_secret_hash);
CREATE INDEX idx_app_labels_app_id ON app_labels(app_id); CREATE INDEX IF NOT EXISTS idx_app_env_vars_app_id ON app_env_vars(app_id);
CREATE INDEX idx_app_volumes_app_id ON app_volumes(app_id); CREATE INDEX IF NOT EXISTS idx_app_labels_app_id ON app_labels(app_id);
CREATE INDEX idx_webhook_events_app_id ON webhook_events(app_id); CREATE INDEX IF NOT EXISTS idx_app_volumes_app_id ON app_volumes(app_id);
CREATE INDEX idx_webhook_events_created_at ON webhook_events(created_at); CREATE INDEX IF NOT EXISTS idx_app_ports_app_id ON app_ports(app_id);
CREATE INDEX idx_deployments_app_id ON deployments(app_id); CREATE INDEX IF NOT EXISTS idx_webhook_events_app_id ON webhook_events(app_id);
CREATE INDEX idx_deployments_started_at ON deployments(started_at); CREATE INDEX IF NOT EXISTS idx_webhook_events_created_at ON webhook_events(created_at);
CREATE INDEX IF NOT EXISTS idx_deployments_app_id ON deployments(app_id);
CREATE INDEX IF NOT EXISTS idx_deployments_started_at ON deployments(started_at);

View File

@@ -1,44 +0,0 @@
-- Remove container_id from apps table
-- Container is now looked up via Docker label (upaas.id) instead of stored in database
-- SQLite doesn't support DROP COLUMN before version 3.35.0 (2021-03-12)
-- Use table rebuild for broader compatibility
-- Create new table without container_id
CREATE TABLE apps_new (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
repo_url TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'main',
dockerfile_path TEXT DEFAULT 'Dockerfile',
webhook_secret TEXT NOT NULL,
ssh_private_key TEXT NOT NULL,
ssh_public_key TEXT NOT NULL,
image_id TEXT,
status TEXT DEFAULT 'pending',
docker_network TEXT,
ntfy_topic TEXT,
slack_webhook TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Copy data (excluding container_id)
INSERT INTO apps_new (
id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
)
SELECT
id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
FROM apps;
-- Drop old table and rename new one
DROP TABLE apps;
ALTER TABLE apps_new RENAME TO apps;
-- Recreate indexes
CREATE INDEX idx_apps_status ON apps(status);
CREATE INDEX idx_apps_webhook_secret ON apps(webhook_secret);

View File

@@ -1,12 +0,0 @@
-- Add port mappings for apps
CREATE TABLE app_ports (
id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
host_port INTEGER NOT NULL,
container_port INTEGER NOT NULL,
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK(protocol IN ('tcp', 'udp')),
UNIQUE(host_port, protocol)
);
CREATE INDEX idx_app_ports_app_id ON app_ports(app_id);

View File

@@ -1,3 +0,0 @@
-- Add commit_url column to webhook_events and deployments tables
ALTER TABLE webhook_events ADD COLUMN commit_url TEXT;
ALTER TABLE deployments ADD COLUMN commit_url TEXT;

View File

@@ -1,2 +0,0 @@
-- Add webhook_secret_hash column for constant-time secret lookup
ALTER TABLE apps ADD COLUMN webhook_secret_hash TEXT NOT NULL DEFAULT '';

View File

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

View File

@@ -1,41 +0,0 @@
package database
import (
"log/slog"
"os"
"testing"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger"
)
// NewTestDatabase creates an in-memory Database for testing.
// It runs migrations so all tables are available.
func NewTestDatabase(t *testing.T) *Database {
t.Helper()
tmpDir := t.TempDir()
cfg := &config.Config{
DataDir: tmpDir,
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
logWrapper := logger.NewForTest(log)
db, err := New(nil, Params{
Logger: logWrapper,
Config: cfg,
})
if err != nil {
t.Fatalf("failed to create test database: %v", err)
}
t.Cleanup(func() {
if db.database != nil {
_ = db.database.Close()
}
})
return db
}

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"
@@ -26,7 +25,6 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
) )
@@ -117,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
} }
@@ -189,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
} }
@@ -242,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, containerID.String(), 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)
} }
@@ -262,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
} }
@@ -271,7 +269,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) err
timeout := stopTimeoutSeconds timeout := stopTimeoutSeconds
err := c.docker.ContainerStop(ctx, containerID.String(), 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)
} }
@@ -282,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 {
@@ -291,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, containerID.String(), 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)
} }
@@ -302,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 {
@@ -315,7 +313,7 @@ func (c *Client) ContainerLogs(
Tail: tail, Tail: tail,
} }
reader, err := c.docker.ContainerLogs(ctx, containerID.String(), 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)
} }
@@ -338,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, containerID.String()) 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)
} }
@@ -355,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, containerID.String()) 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)
} }
@@ -379,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
} }
@@ -414,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
} }
@@ -481,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, imageID.String(), 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 {
@@ -513,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,
@@ -543,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
@@ -604,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, gitContainerID.String(), 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.
@@ -676,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, containerID.String(), 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, containerID.String(), container.WaitConditionNotRunning) statusCh, errCh := c.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
select { select {
case err := <-errCh: case err := <-errCh:

View File

@@ -1,13 +0,0 @@
package docker
// ImageID is a Docker image identifier (ID or tag).
type ImageID string
// String implements the fmt.Stringer interface.
func (id ImageID) String() string { return string(id) }
// ContainerID is a Docker container identifier.
type ContainerID string
// String implements the fmt.Stringer interface.
func (id ContainerID) String() string { return string(id) }

View File

@@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"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.
@@ -73,13 +74,18 @@ func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials // HandleAPILoginPOST returns a handler that authenticates via JSON credentials
// and sets a session cookie. // and sets a session cookie.
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc { func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type loginResponse struct { type loginResponse struct {
UserID int64 `json:"userId"` UserID int64 `json:"userId"`
Username string `json:"username"` Username string `json:"username"`
} }
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
var req map[string]string var req loginRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req) decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil { if decodeErr != nil {
@@ -90,10 +96,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
return return
} }
username := req["username"] if req.Username == "" || req.Password == "" {
credential := req["password"]
if username == "" || credential == "" {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{"error": "username and password are required"}, map[string]string{"error": "username and password are required"},
http.StatusBadRequest) http.StatusBadRequest)
@@ -101,7 +104,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
return return
} }
user, authErr := h.auth.Authenticate(request.Context(), username, credential) user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password)
if authErr != nil { if authErr != nil {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{"error": "invalid credentials"}, map[string]string{"error": "invalid credentials"},
@@ -174,6 +177,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 +323,35 @@ 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)
}
}
// 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 {

View File

@@ -10,8 +10,6 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"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"
) )
// apiRouter builds a chi router with the API routes using session auth middleware. // apiRouter builds a chi router with the API routes using session auth middleware.
@@ -25,7 +23,10 @@ func apiRouter(tc *testContext) http.Handler {
apiR.Use(tc.middleware.APISessionAuth()) apiR.Use(tc.middleware.APISessionAuth())
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI()) apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
apiR.Get("/apps", tc.handlers.HandleAPIListApps()) apiR.Get("/apps", tc.handlers.HandleAPIListApps())
apiR.Post("/apps", tc.handlers.HandleAPICreateApp())
apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp()) 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()) apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments())
}) })
}) })
@@ -61,16 +62,23 @@ func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
return tc, cookies return tc, cookies
} }
// apiGet makes an authenticated GET request using session cookies. // apiRequest makes an authenticated API request using session cookies.
func apiGet( func apiRequest(
t *testing.T, t *testing.T,
tc *testContext, tc *testContext,
cookies []*http.Cookie, cookies []*http.Cookie,
path string, method, 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 != "" {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
for _, c := range cookies { for _, c := range cookies {
req.AddCookie(c) req.AddCookie(c)
@@ -167,7 +175,7 @@ func TestAPIWhoAmI(t *testing.T) {
tc, cookies := setupAPITest(t) tc, cookies := setupAPITest(t)
rr := apiGet(t, tc, cookies, "/api/v1/whoami") rr := apiRequest(t, tc, cookies, 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
@@ -180,7 +188,7 @@ func TestAPIListAppsEmpty(t *testing.T) {
tc, cookies := setupAPITest(t) tc, cookies := setupAPITest(t)
rr := apiGet(t, tc, cookies, "/api/v1/apps") rr := apiRequest(t, tc, cookies, 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,23 +196,52 @@ func TestAPIListAppsEmpty(t *testing.T) {
assert.Empty(t, apps) assert.Empty(t, apps)
} }
func TestAPICreateApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"test-app","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
assert.Equal(t, http.StatusCreated, rr.Code)
var app map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
assert.Equal(t, "test-app", app["name"])
assert.Equal(t, "pending", app["status"])
}
func TestAPICreateAppValidation(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"","repoUrl":""}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestAPIGetApp(t *testing.T) { func TestAPIGetApp(t *testing.T) {
t.Parallel() t.Parallel()
tc, cookies := setupAPITest(t) tc, cookies := setupAPITest(t)
created, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{ body := `{"name":"my-app","repoUrl":"https://github.com/example/repo"}`
Name: "my-app", rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
RepoURL: "https://github.com/example/repo", 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)
rr = apiRequest(t, tc, cookies, 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) {
@@ -212,7 +249,29 @@ func TestAPIGetAppNotFound(t *testing.T) {
tc, cookies := setupAPITest(t) tc, cookies := setupAPITest(t)
rr := apiGet(t, tc, cookies, "/api/v1/apps/nonexistent") rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/nonexistent", "")
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestAPIDeleteApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"delete-me","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodDelete, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusOK, rr.Code)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
} }
@@ -221,13 +280,17 @@ func TestAPIListDeployments(t *testing.T) {
tc, cookies := setupAPITest(t) tc, cookies := setupAPITest(t)
created, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{ body := `{"name":"deploy-app","repoUrl":"https://github.com/example/repo"}`
Name: "deploy-app", rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
RepoURL: "https://github.com/example/repo", 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)
rr = apiRequest(t, tc, cookies, 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

View File

@@ -72,15 +72,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 +220,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
} }
@@ -518,7 +499,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 +534,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 +581,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 +661,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 +897,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 +1003,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

View File

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

View File

@@ -404,25 +404,6 @@ func TestHandleDashboard(t *testing.T) {
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Applications") assert.Contains(t, recorder.Body.String(), "Applications")
}) })
t.Run("renders dashboard with apps without crashing on CSRFField", func(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// Create an app so the template iterates over AppStats and hits .CSRFField
createTestApp(t, testCtx, "csrf-test-app")
request := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code,
"dashboard should not 500 when apps exist (CSRFField must be accessible)")
assert.Contains(t, recorder.Body.String(), "csrf-test-app")
})
} }
func TestHandleAppNew(t *testing.T) { func TestHandleAppNew(t *testing.T) {
@@ -583,7 +564,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) {
@@ -714,153 +695,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,11 +0,0 @@
package logger
import "log/slog"
// NewForTest creates a Logger wrapping the given slog.Logger, for use in tests.
func NewForTest(log *slog.Logger) *Logger {
return &Logger{
log: log,
level: new(slog.LevelVar),
}
}

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

@@ -177,48 +177,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 == "" {
@@ -266,9 +235,9 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
// loginRateLimit configures the login rate limiter. // loginRateLimit configures the login rate limiter.
const ( const (
loginRateLimit = rate.Limit(5.0 / 60.0) // 5 requests per 60 seconds loginRateLimit = rate.Limit(5.0 / 60.0) // 5 requests per 60 seconds
loginBurst = 5 // allow burst of 5 loginBurst = 5 // allow burst of 5
limiterExpiry = 10 * time.Minute // evict entries not seen in 10 minutes limiterExpiry = 10 * time.Minute // evict entries not seen in 10 minutes
limiterCleanupEvery = 1 * time.Minute // sweep interval limiterCleanupEvery = 1 * time.Minute // sweep interval
) )
// ipLimiterEntry stores a rate limiter with its last-seen timestamp. // ipLimiterEntry stores a rate limiter with its last-seen timestamp.
@@ -280,8 +249,8 @@ type ipLimiterEntry struct {
// ipLimiter tracks per-IP rate limiters for login attempts with automatic // ipLimiter tracks per-IP rate limiters for login attempts with automatic
// eviction of stale entries to prevent unbounded memory growth. // eviction of stale entries to prevent unbounded memory growth.
type ipLimiter struct { type ipLimiter struct {
mu sync.Mutex mu sync.Mutex
limiters map[string]*ipLimiterEntry limiters map[string]*ipLimiterEntry
lastSweep time.Time lastSweep time.Time
} }
@@ -411,14 +380,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

@@ -32,23 +32,23 @@ const (
type App struct { type App struct {
db *database.Database db *database.Database
ID string ID string
Name string Name string
RepoURL string RepoURL string
Branch string Branch string
DockerfilePath string DockerfilePath string
WebhookSecret string WebhookSecret string
WebhookSecretHash string WebhookSecretHash string
SSHPrivateKey string SSHPrivateKey string
SSHPublicKey string SSHPublicKey string
ImageID sql.NullString ImageID sql.NullString
PreviousImageID sql.NullString PreviousImageID sql.NullString
Status AppStatus Status AppStatus
DockerNetwork sql.NullString DockerNetwork sql.NullString
NtfyTopic sql.NullString NtfyTopic sql.NullString
SlackWebhook sql.NullString SlackWebhook sql.NullString
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
// NewApp creates a new App with a database reference. // NewApp creates a new App with a database reference.

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

@@ -54,51 +54,51 @@ func (s *Server) SetupRoutes() {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(s.mw.SessionAuth()) r.Use(s.mw.SessionAuth())
// Dashboard // Dashboard
r.Get("/", s.handlers.HandleDashboard()) r.Get("/", s.handlers.HandleDashboard())
// Logout // Logout
r.Post("/logout", s.handlers.HandleLogout()) r.Post("/logout", s.handlers.HandleLogout())
// App routes // App routes
r.Get("/apps/new", s.handlers.HandleAppNew()) r.Get("/apps/new", s.handlers.HandleAppNew())
r.Post("/apps", s.handlers.HandleAppCreate()) r.Post("/apps", s.handlers.HandleAppCreate())
r.Get("/apps/{id}", s.handlers.HandleAppDetail()) r.Get("/apps/{id}", s.handlers.HandleAppDetail())
r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit()) r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit())
r.Post("/apps/{id}", s.handlers.HandleAppUpdate()) r.Post("/apps/{id}", s.handlers.HandleAppUpdate())
r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete()) r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete())
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy()) r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy()) r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments()) r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI()) r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload()) r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs()) r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI()) r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI()) r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI()) r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI())
r.Post("/apps/{id}/rollback", s.handlers.HandleAppRollback()) r.Post("/apps/{id}/rollback", s.handlers.HandleAppRollback())
r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart()) r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart())
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop()) r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
r.Post("/apps/{id}/start", s.handlers.HandleAppStart()) r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
// Environment variables // Environment variables
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd()) r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit()) r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete()) r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
// Labels // Labels
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd()) r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit()) r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit())
r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete()) r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete())
// Volumes // Volumes
r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd()) r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd())
r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit()) r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit())
r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete()) r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete())
// Ports // Ports
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd()) r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete()) r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
}) })
}) })
@@ -114,7 +114,10 @@ func (s *Server) SetupRoutes() {
r.Get("/whoami", s.handlers.HandleAPIWhoAmI()) r.Get("/whoami", s.handlers.HandleAPIWhoAmI())
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())
}) })
}) })

View File

@@ -11,7 +11,6 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"time" "time"
@@ -83,7 +82,7 @@ type deploymentLogWriter struct {
lineBuffer bytes.Buffer // buffer for incomplete lines lineBuffer bytes.Buffer // buffer for incomplete lines
mu sync.Mutex mu sync.Mutex
done chan struct{} done chan struct{}
flushed sync.WaitGroup // waits for flush goroutine to finish flushed sync.WaitGroup // waits for flush goroutine to finish
flushCtx context.Context //nolint:containedctx // needed for async flush goroutine flushCtx context.Context //nolint:containedctx // needed for async flush goroutine
} }
@@ -251,8 +250,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.
@@ -417,13 +416,15 @@ func (svc *Service) executeRollback(
svc.removeOldContainer(ctx, app, deployment) svc.removeOldContainer(ctx, app, deployment)
rollbackOpts, err := svc.buildContainerOptions(ctx, app, docker.ImageID(previousImageID)) rollbackOpts, err := svc.buildContainerOptions(ctx, app, deployment.ID)
if err != nil { if err != nil {
svc.failDeployment(bgCtx, app, deployment, err) svc.failDeployment(bgCtx, app, deployment, err)
return fmt.Errorf("failed to build container options: %w", err) return fmt.Errorf("failed to build container options: %w", err)
} }
rollbackOpts.Image = previousImageID
containerID, err := svc.docker.CreateContainer(ctx, rollbackOpts) containerID, err := svc.docker.CreateContainer(ctx, rollbackOpts)
if err != nil { if err != nil {
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to create rollback container: %w", err)) svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to create rollback container: %w", err))
@@ -431,8 +432,8 @@ func (svc *Service) executeRollback(
return fmt.Errorf("failed to create rollback container: %w", err) return fmt.Errorf("failed to create rollback container: %w", err)
} }
deployment.ContainerID = sql.NullString{String: containerID.String(), Valid: true} deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID.String()) _ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID)
startErr := svc.docker.StartContainer(ctx, containerID) startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil { if startErr != nil {
@@ -471,7 +472,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 +485,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
} }
@@ -514,7 +515,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 +540,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 +661,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 "+imageID.String()+": "+removeErr.Error())
} else {
svc.log.Info("cleaned up image from cancelled deploy",
"app", app.Name, "image", imageID)
_ = deployment.AppendLog(ctx, "Cleaned up intermediate image: "+imageID.String())
}
}
// 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 +764,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 +798,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: imageID.String(), Valid: true} deployment.ImageID = sql.NullString{String: imageID, Valid: true}
_ = deployment.AppendLog(ctx, "Image built: "+imageID.String()) _ = deployment.AppendLog(ctx, "Image built: "+imageID)
return imageID, nil return imageID, nil
} }
@@ -1009,16 +957,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 +986,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: containerID.String(), Valid: true} deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
_ = deployment.AppendLog(ctx, "Container created: "+containerID.String()) _ = 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 +1010,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 +1044,7 @@ func (svc *Service) buildContainerOptions(
return docker.CreateContainerOptions{ return docker.CreateContainerOptions{
Name: "upaas-" + app.Name, Name: "upaas-" + app.Name,
Image: imageID.String(), 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 +1094,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: imageID.String(), 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

@@ -1,45 +0,0 @@
package deploy_test
import (
"context"
"log/slog"
"os"
"testing"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
)
func TestBuildContainerOptionsUsesImageID(t *testing.T) {
t.Parallel()
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = "myapp"
err := app.Save(context.Background())
if err != nil {
t.Fatalf("failed to save app: %v", err)
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
svc := deploy.NewTestService(log)
const expectedImageID = docker.ImageID("sha256:abc123def456")
opts, err := svc.BuildContainerOptionsExported(context.Background(), app, expectedImageID)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
if opts.Image != expectedImageID.String() {
t.Errorf("expected Image=%q, got %q", expectedImageID, opts.Image)
}
if opts.Name != "upaas-myapp" {
t.Errorf("expected Name=%q, got %q", "upaas-myapp", opts.Name)
}
}

View File

@@ -2,15 +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"
"git.eeqj.de/sneak/upaas/internal/models"
) )
// NewTestService creates a Service with minimal dependencies for testing. // NewTestService creates a Service with minimal dependencies for testing.
@@ -39,54 +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)
}
// BuildContainerOptionsExported exposes buildContainerOptions for testing.
func (svc *Service) BuildContainerOptionsExported(
ctx context.Context,
app *models.App,
imageID docker.ImageID,
) (docker.CreateContainerOptions, error) {
return svc.buildContainerOptions(ctx, app, imageID)
}

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,10 +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
// String implements the fmt.Stringer interface.
func (u UnparsedURL) String() string { return string(u) }

View File

@@ -11,7 +11,6 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"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/models"
"git.eeqj.de/sneak/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
@@ -48,24 +47,24 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
// //
//nolint:tagliatelle // Field names match Gitea API (snake_case) //nolint:tagliatelle // Field names match Gitea API (snake_case)
type GiteaPushPayload struct { 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"`
Email string `json:"email"` Email string `json:"email"`
} `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"`
Email string `json:"email"` Email string `json:"email"`
@@ -105,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: commitURL.String(), 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
@@ -169,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 != "" {
@@ -179,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(payload.Repository.HTMLURL.String() + "/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
} }

3047
static/js/alpine.min.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -1,194 +0,0 @@
/**
* upaas - App Detail Page Component
*
* Handles the single-app view: status polling, container logs,
* build logs, and recent deployments list.
*/
document.addEventListener("alpine:init", () => {
Alpine.data("appDetail", (config) => ({
appId: config.appId,
currentDeploymentId: config.initialDeploymentId,
appStatus: config.initialStatus || "unknown",
containerLogs: "Loading container logs...",
containerStatus: "unknown",
buildLogs: config.initialDeploymentId
? "Loading build logs..."
: "No deployments yet",
buildStatus: config.initialBuildStatus || "unknown",
showBuildLogs: !!config.initialDeploymentId,
deploying: false,
deployments: [],
// Track whether user wants auto-scroll (per log pane)
_containerAutoScroll: true,
_buildAutoScroll: true,
_pollTimer: null,
init() {
this.deploying = Alpine.store("utils").isDeploying(this.appStatus);
this.fetchAll();
this._schedulePoll();
// Set up scroll listeners after DOM is ready
this.$nextTick(() => {
this._initScrollTracking(this.$refs.containerLogsWrapper, '_containerAutoScroll');
this._initScrollTracking(this.$refs.buildLogsWrapper, '_buildAutoScroll');
});
},
_schedulePoll() {
if (this._pollTimer) clearTimeout(this._pollTimer);
const interval = Alpine.store("utils").isDeploying(this.appStatus) ? 1000 : 10000;
this._pollTimer = setTimeout(() => {
this.fetchAll();
this._schedulePoll();
}, interval);
},
_initScrollTracking(el, flag) {
if (!el) return;
el.addEventListener('scroll', () => {
this[flag] = Alpine.store("utils").isScrolledToBottom(el);
}, { passive: true });
},
fetchAll() {
this.fetchAppStatus();
// Only fetch logs when the respective pane is visible
if (this.$refs.containerLogsWrapper && this._isElementVisible(this.$refs.containerLogsWrapper)) {
this.fetchContainerLogs();
}
if (this.showBuildLogs && this.$refs.buildLogsWrapper && this._isElementVisible(this.$refs.buildLogsWrapper)) {
this.fetchBuildLogs();
}
this.fetchRecentDeployments();
},
_isElementVisible(el) {
if (!el) return false;
// Check if element is in viewport (roughly)
const rect = el.getBoundingClientRect();
return rect.bottom > 0 && rect.top < window.innerHeight;
},
async fetchAppStatus() {
try {
const res = await fetch(`/apps/${this.appId}/status`);
const data = await res.json();
const wasDeploying = this.deploying;
this.appStatus = data.status;
this.deploying = Alpine.store("utils").isDeploying(data.status);
// Re-schedule polling when deployment state changes
if (this.deploying !== wasDeploying) {
this._schedulePoll();
}
if (
data.latestDeploymentID &&
data.latestDeploymentID !== this.currentDeploymentId
) {
this.currentDeploymentId = data.latestDeploymentID;
this.showBuildLogs = true;
this.fetchBuildLogs();
}
} catch (err) {
console.error("Status fetch error:", err);
}
},
async fetchContainerLogs() {
try {
const res = await fetch(`/apps/${this.appId}/container-logs`);
const data = await res.json();
const newLogs = data.logs || "No logs available";
const changed = newLogs !== this.containerLogs;
this.containerLogs = newLogs;
this.containerStatus = data.status;
if (changed && this._containerAutoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
});
}
} catch (err) {
this.containerLogs = "Failed to fetch logs";
}
},
async fetchBuildLogs() {
if (!this.currentDeploymentId) return;
try {
const res = await fetch(
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
);
const data = await res.json();
const newLogs = data.logs || "No build logs available";
const changed = newLogs !== this.buildLogs;
this.buildLogs = newLogs;
this.buildStatus = data.status;
if (changed && this._buildAutoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
});
}
} catch (err) {
this.buildLogs = "Failed to fetch logs";
}
},
async fetchRecentDeployments() {
try {
const res = await fetch(`/apps/${this.appId}/recent-deployments`);
const data = await res.json();
this.deployments = data.deployments || [];
} catch (err) {
console.error("Deployments fetch error:", err);
}
},
submitDeploy() {
this.deploying = true;
},
get statusBadgeClass() {
return Alpine.store("utils").statusBadgeClass(this.appStatus);
},
get statusLabel() {
return Alpine.store("utils").statusLabel(this.appStatus);
},
get containerStatusBadgeClass() {
return (
Alpine.store("utils").statusBadgeClass(this.containerStatus) +
" text-xs"
);
},
get containerStatusLabel() {
return Alpine.store("utils").statusLabel(this.containerStatus);
},
get buildStatusBadgeClass() {
return (
Alpine.store("utils").statusBadgeClass(this.buildStatus) + " text-xs"
);
},
get buildStatusLabel() {
return Alpine.store("utils").statusLabel(this.buildStatus);
},
deploymentStatusClass(status) {
return Alpine.store("utils").statusBadgeClass(status);
},
deploymentStatusLabel(status) {
return Alpine.store("utils").statusLabel(status);
},
formatTime(isoTime) {
return Alpine.store("utils").formatRelativeTime(isoTime);
},
}));
});

581
static/js/app.js Normal file
View File

@@ -0,0 +1,581 @@
/**
* upaas - Frontend JavaScript with Alpine.js
*/
document.addEventListener("alpine:init", () => {
// ============================================
// Global Utilities Store
// ============================================
Alpine.store("utils", {
/**
* Format a date string as relative time (e.g., "5 minutes ago")
*/
formatRelativeTime(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return "just now";
if (diffMin < 60)
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago");
if (diffHour < 24)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffDay < 7)
return diffDay + (diffDay === 1 ? " day ago" : " days ago");
return date.toLocaleDateString();
},
/**
* Get the badge class for a given status
*/
statusBadgeClass(status) {
if (status === "running" || status === "success") return "badge-success";
if (status === "building" || status === "deploying")
return "badge-warning";
if (status === "failed" || status === "error") return "badge-error";
return "badge-neutral";
},
/**
* Format status for display (capitalize first letter)
*/
statusLabel(status) {
if (!status) return "";
return status.charAt(0).toUpperCase() + status.slice(1);
},
/**
* Check if status indicates active deployment
*/
isDeploying(status) {
return status === "building" || status === "deploying";
},
/**
* Scroll an element to the bottom
*/
scrollToBottom(el) {
if (el) {
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}
},
/**
* Check if a scrollable element is at (or near) the bottom.
* Tolerance of 30px accounts for rounding and partial lines.
*/
isScrolledToBottom(el, tolerance = 30) {
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance;
},
/**
* Copy text to clipboard
*/
async copyToClipboard(text, button) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
document.body.removeChild(textArea);
return true;
} catch (e) {
document.body.removeChild(textArea);
return false;
}
}
},
});
// ============================================
// Copy Button Component
// ============================================
Alpine.data("copyButton", (targetId) => ({
copied: false,
async copy() {
const target = document.getElementById(targetId);
if (!target) return;
const text = target.textContent || target.value;
const success = await Alpine.store("utils").copyToClipboard(text);
if (success) {
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 2000);
}
},
}));
// ============================================
// Confirm Action Component
// ============================================
Alpine.data("confirmAction", (message) => ({
confirm(event) {
if (!window.confirm(message)) {
event.preventDefault();
}
},
}));
// ============================================
// Auto-dismiss Alert Component
// ============================================
Alpine.data("autoDismiss", (delay = 5000) => ({
show: true,
init() {
setTimeout(() => {
this.dismiss();
}, delay);
},
dismiss() {
this.show = false;
setTimeout(() => {
this.$el.remove();
}, 300);
},
}));
// ============================================
// Relative Time Component
// ============================================
Alpine.data("relativeTime", (isoTime) => ({
display: "",
init() {
this.update();
// Update every minute
setInterval(() => this.update(), 60000);
},
update() {
this.display = Alpine.store("utils").formatRelativeTime(isoTime);
},
}));
// ============================================
// App Detail Page Component
// ============================================
Alpine.data("appDetail", (config) => ({
appId: config.appId,
currentDeploymentId: config.initialDeploymentId,
appStatus: config.initialStatus || "unknown",
containerLogs: "Loading container logs...",
containerStatus: "unknown",
buildLogs: config.initialDeploymentId
? "Loading build logs..."
: "No deployments yet",
buildStatus: config.initialBuildStatus || "unknown",
showBuildLogs: !!config.initialDeploymentId,
deploying: false,
deployments: [],
// Track whether user wants auto-scroll (per log pane)
_containerAutoScroll: true,
_buildAutoScroll: true,
_pollTimer: null,
init() {
this.deploying = Alpine.store("utils").isDeploying(this.appStatus);
this.fetchAll();
this._schedulePoll();
// Set up scroll listeners after DOM is ready
this.$nextTick(() => {
this._initScrollTracking(this.$refs.containerLogsWrapper, '_containerAutoScroll');
this._initScrollTracking(this.$refs.buildLogsWrapper, '_buildAutoScroll');
});
},
_schedulePoll() {
if (this._pollTimer) clearTimeout(this._pollTimer);
const interval = Alpine.store("utils").isDeploying(this.appStatus) ? 1000 : 10000;
this._pollTimer = setTimeout(() => {
this.fetchAll();
this._schedulePoll();
}, interval);
},
_initScrollTracking(el, flag) {
if (!el) return;
el.addEventListener('scroll', () => {
this[flag] = Alpine.store("utils").isScrolledToBottom(el);
}, { passive: true });
},
fetchAll() {
this.fetchAppStatus();
// Only fetch logs when the respective pane is visible
if (this.$refs.containerLogsWrapper && this._isElementVisible(this.$refs.containerLogsWrapper)) {
this.fetchContainerLogs();
}
if (this.showBuildLogs && this.$refs.buildLogsWrapper && this._isElementVisible(this.$refs.buildLogsWrapper)) {
this.fetchBuildLogs();
}
this.fetchRecentDeployments();
},
_isElementVisible(el) {
if (!el) return false;
// Check if element is in viewport (roughly)
const rect = el.getBoundingClientRect();
return rect.bottom > 0 && rect.top < window.innerHeight;
},
async fetchAppStatus() {
try {
const res = await fetch(`/apps/${this.appId}/status`);
const data = await res.json();
const wasDeploying = this.deploying;
this.appStatus = data.status;
this.deploying = Alpine.store("utils").isDeploying(data.status);
// Re-schedule polling when deployment state changes
if (this.deploying !== wasDeploying) {
this._schedulePoll();
}
if (
data.latestDeploymentID &&
data.latestDeploymentID !== this.currentDeploymentId
) {
this.currentDeploymentId = data.latestDeploymentID;
this.showBuildLogs = true;
this.fetchBuildLogs();
}
} catch (err) {
console.error("Status fetch error:", err);
}
},
async fetchContainerLogs() {
try {
const res = await fetch(`/apps/${this.appId}/container-logs`);
const data = await res.json();
const newLogs = data.logs || "No logs available";
const changed = newLogs !== this.containerLogs;
this.containerLogs = newLogs;
this.containerStatus = data.status;
if (changed && this._containerAutoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
});
}
} catch (err) {
this.containerLogs = "Failed to fetch logs";
}
},
async fetchBuildLogs() {
if (!this.currentDeploymentId) return;
try {
const res = await fetch(
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
);
const data = await res.json();
const newLogs = data.logs || "No build logs available";
const changed = newLogs !== this.buildLogs;
this.buildLogs = newLogs;
this.buildStatus = data.status;
if (changed && this._buildAutoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
});
}
} catch (err) {
this.buildLogs = "Failed to fetch logs";
}
},
async fetchRecentDeployments() {
try {
const res = await fetch(`/apps/${this.appId}/recent-deployments`);
const data = await res.json();
this.deployments = data.deployments || [];
} catch (err) {
console.error("Deployments fetch error:", err);
}
},
submitDeploy() {
this.deploying = true;
},
get statusBadgeClass() {
return Alpine.store("utils").statusBadgeClass(this.appStatus);
},
get statusLabel() {
return Alpine.store("utils").statusLabel(this.appStatus);
},
get containerStatusBadgeClass() {
return (
Alpine.store("utils").statusBadgeClass(this.containerStatus) +
" text-xs"
);
},
get containerStatusLabel() {
return Alpine.store("utils").statusLabel(this.containerStatus);
},
get buildStatusBadgeClass() {
return (
Alpine.store("utils").statusBadgeClass(this.buildStatus) + " text-xs"
);
},
get buildStatusLabel() {
return Alpine.store("utils").statusLabel(this.buildStatus);
},
deploymentStatusClass(status) {
return Alpine.store("utils").statusBadgeClass(status);
},
deploymentStatusLabel(status) {
return Alpine.store("utils").statusLabel(status);
},
formatTime(isoTime) {
return Alpine.store("utils").formatRelativeTime(isoTime);
},
}));
// ============================================
// Deployment Card Component (for individual deployment cards)
// ============================================
Alpine.data("deploymentCard", (config) => ({
appId: config.appId,
deploymentId: config.deploymentId,
logs: "",
status: config.status || "",
pollInterval: null,
_autoScroll: true,
init() {
// Read initial logs from script tag (avoids escaping issues)
const initialLogsEl = this.$el.querySelector(".initial-logs");
this.logs = initialLogsEl?.textContent || "Loading...";
// Set up scroll tracking
this.$nextTick(() => {
const wrapper = this.$refs.logsWrapper;
if (wrapper) {
wrapper.addEventListener('scroll', () => {
this._autoScroll = Alpine.store("utils").isScrolledToBottom(wrapper);
}, { passive: true });
}
});
// Only poll if deployment is in progress
if (Alpine.store("utils").isDeploying(this.status)) {
this.fetchLogs();
this.pollInterval = setInterval(() => this.fetchLogs(), 1000);
}
},
destroy() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
},
async fetchLogs() {
try {
const res = await fetch(
`/apps/${this.appId}/deployments/${this.deploymentId}/logs`,
);
const data = await res.json();
const newLogs = data.logs || "No logs available";
const logsChanged = newLogs !== this.logs;
this.logs = newLogs;
this.status = data.status;
// Scroll to bottom only when content changes AND user hasn't scrolled up
if (logsChanged && this._autoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper);
});
}
// Stop polling if deployment is done
if (!Alpine.store("utils").isDeploying(data.status)) {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
// Reload page to show final state with duration etc
window.location.reload();
}
} catch (err) {
console.error("Logs fetch error:", err);
}
},
get statusBadgeClass() {
return Alpine.store("utils").statusBadgeClass(this.status);
},
get statusLabel() {
return Alpine.store("utils").statusLabel(this.status);
},
}));
// ============================================
// Deployments History Page Component
// ============================================
Alpine.data("deploymentsPage", (config) => ({
appId: config.appId,
currentDeploymentId: null,
isDeploying: false,
init() {
// Check for in-progress deployments on page load
const inProgressCard = document.querySelector(
'[data-status="building"], [data-status="deploying"]',
);
if (inProgressCard) {
this.currentDeploymentId = parseInt(
inProgressCard.getAttribute("data-deployment-id"),
10,
);
this.isDeploying = true;
}
this.fetchAppStatus();
this._scheduleStatusPoll();
},
_statusPollTimer: null,
_scheduleStatusPoll() {
if (this._statusPollTimer) clearTimeout(this._statusPollTimer);
const interval = this.isDeploying ? 1000 : 10000;
this._statusPollTimer = setTimeout(() => {
this.fetchAppStatus();
this._scheduleStatusPoll();
}, interval);
},
async fetchAppStatus() {
try {
const res = await fetch(`/apps/${this.appId}/status`);
const data = await res.json();
// Use deployment status, not app status - it's more reliable during transitions
const deploying = Alpine.store("utils").isDeploying(
data.latestDeploymentStatus,
);
// Detect new deployment
if (
data.latestDeploymentID &&
data.latestDeploymentID !== this.currentDeploymentId
) {
// Check if we have a card for this deployment
const hasCard = document.querySelector(
`[data-deployment-id="${data.latestDeploymentID}"]`,
);
if (deploying && !hasCard) {
// New deployment started but no card exists - reload to show it
window.location.reload();
return;
}
this.currentDeploymentId = data.latestDeploymentID;
}
// Update deploying state based on latest deployment status
if (deploying && !this.isDeploying) {
this.isDeploying = true;
this._scheduleStatusPoll(); // Switch to fast polling
} else if (!deploying && this.isDeploying) {
// Deployment finished - reload to show final state
this.isDeploying = false;
window.location.reload();
}
} catch (err) {
console.error("Status fetch error:", err);
}
},
submitDeploy() {
this.isDeploying = true;
},
formatTime(isoTime) {
return Alpine.store("utils").formatRelativeTime(isoTime);
},
}));
// ============================================
// Dashboard Page - Relative Time Updates
// ============================================
Alpine.data("dashboard", () => ({
init() {
// Update relative times every minute
setInterval(() => {
this.$el.querySelectorAll("[data-time]").forEach((el) => {
const time = el.getAttribute("data-time");
if (time) {
el.textContent = Alpine.store("utils").formatRelativeTime(time);
}
});
}, 60000);
},
}));
});
// ============================================
// Legacy support - expose utilities globally
// ============================================
window.upaas = {
// These are kept for backwards compatibility but templates should use Alpine.js
formatRelativeTime(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return "just now";
if (diffMin < 60)
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago");
if (diffHour < 24)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffDay < 7)
return diffDay + (diffDay === 1 ? " day ago" : " days ago");
return date.toLocaleDateString();
},
// Placeholder functions - templates should migrate to Alpine.js
initAppDetailPage() {},
initDeploymentsPage() {},
};
// Update relative times on page load for non-Alpine elements
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".relative-time[data-time]").forEach((el) => {
const time = el.getAttribute("data-time");
if (time) {
el.textContent = window.upaas.formatRelativeTime(time);
}
});
});

View File

@@ -1,71 +0,0 @@
/**
* upaas - Reusable Alpine.js Components
*
* Small, self-contained components: copy button, confirm dialog,
* auto-dismiss alerts, and relative time display.
*/
document.addEventListener("alpine:init", () => {
// ============================================
// Copy Button Component
// ============================================
Alpine.data("copyButton", (targetId) => ({
copied: false,
async copy() {
const target = document.getElementById(targetId);
if (!target) return;
const text = target.textContent || target.value;
const success = await Alpine.store("utils").copyToClipboard(text);
if (success) {
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 2000);
}
},
}));
// ============================================
// Confirm Action Component
// ============================================
Alpine.data("confirmAction", (message) => ({
confirm(event) {
if (!window.confirm(message)) {
event.preventDefault();
}
},
}));
// ============================================
// Auto-dismiss Alert Component
// ============================================
Alpine.data("autoDismiss", (delay = 5000) => ({
show: true,
init() {
setTimeout(() => {
this.dismiss();
}, delay);
},
dismiss() {
this.show = false;
setTimeout(() => {
this.$el.remove();
}, 300);
},
}));
// ============================================
// Relative Time Component
// ============================================
Alpine.data("relativeTime", (isoTime) => ({
display: "",
init() {
this.update();
// Update every minute
setInterval(() => this.update(), 60000);
},
update() {
this.display = Alpine.store("utils").formatRelativeTime(isoTime);
},
}));
});

View File

@@ -1,21 +0,0 @@
/**
* upaas - Dashboard Page Component
*
* Periodically updates relative timestamps on the main dashboard.
*/
document.addEventListener("alpine:init", () => {
Alpine.data("dashboard", () => ({
init() {
// Update relative times every minute
setInterval(() => {
this.$el.querySelectorAll("[data-time]").forEach((el) => {
const time = el.getAttribute("data-time");
if (time) {
el.textContent = Alpine.store("utils").formatRelativeTime(time);
}
});
}, 60000);
},
}));
});

View File

@@ -1,176 +0,0 @@
/**
* upaas - Deployment Components
*
* Deployment card (individual deployment log viewer) and
* deployments history page (list of all deployments).
*/
document.addEventListener("alpine:init", () => {
// ============================================
// Deployment Card Component (for individual deployment cards)
// ============================================
Alpine.data("deploymentCard", (config) => ({
appId: config.appId,
deploymentId: config.deploymentId,
logs: "",
status: config.status || "",
pollInterval: null,
_autoScroll: true,
init() {
// Read initial logs from script tag (avoids escaping issues)
const initialLogsEl = this.$el.querySelector(".initial-logs");
this.logs = initialLogsEl?.dataset.logs || "Loading...";
// Set up scroll tracking
this.$nextTick(() => {
const wrapper = this.$refs.logsWrapper;
if (wrapper) {
wrapper.addEventListener('scroll', () => {
this._autoScroll = Alpine.store("utils").isScrolledToBottom(wrapper);
}, { passive: true });
}
});
// Only poll if deployment is in progress
if (Alpine.store("utils").isDeploying(this.status)) {
this.fetchLogs();
this.pollInterval = setInterval(() => this.fetchLogs(), 1000);
}
},
destroy() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
},
async fetchLogs() {
try {
const res = await fetch(
`/apps/${this.appId}/deployments/${this.deploymentId}/logs`,
);
const data = await res.json();
const newLogs = data.logs || "No logs available";
const logsChanged = newLogs !== this.logs;
this.logs = newLogs;
this.status = data.status;
// Scroll to bottom only when content changes AND user hasn't scrolled up
if (logsChanged && this._autoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper);
});
}
// Stop polling if deployment is done
if (!Alpine.store("utils").isDeploying(data.status)) {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
// Reload page to show final state with duration etc
window.location.reload();
}
} catch (err) {
console.error("Logs fetch error:", err);
}
},
get statusBadgeClass() {
return Alpine.store("utils").statusBadgeClass(this.status);
},
get statusLabel() {
return Alpine.store("utils").statusLabel(this.status);
},
}));
// ============================================
// Deployments History Page Component
// ============================================
Alpine.data("deploymentsPage", (config) => ({
appId: config.appId,
currentDeploymentId: null,
isDeploying: false,
init() {
// Check for in-progress deployments on page load
const inProgressCard = document.querySelector(
'[data-status="building"], [data-status="deploying"]',
);
if (inProgressCard) {
this.currentDeploymentId = parseInt(
inProgressCard.getAttribute("data-deployment-id"),
10,
);
this.isDeploying = true;
}
this.fetchAppStatus();
this._scheduleStatusPoll();
},
_statusPollTimer: null,
_scheduleStatusPoll() {
if (this._statusPollTimer) clearTimeout(this._statusPollTimer);
const interval = this.isDeploying ? 1000 : 10000;
this._statusPollTimer = setTimeout(() => {
this.fetchAppStatus();
this._scheduleStatusPoll();
}, interval);
},
async fetchAppStatus() {
try {
const res = await fetch(`/apps/${this.appId}/status`);
const data = await res.json();
// Use deployment status, not app status - it's more reliable during transitions
const deploying = Alpine.store("utils").isDeploying(
data.latestDeploymentStatus,
);
// Detect new deployment
if (
data.latestDeploymentID &&
data.latestDeploymentID !== this.currentDeploymentId
) {
// Check if we have a card for this deployment
const hasCard = document.querySelector(
`[data-deployment-id="${data.latestDeploymentID}"]`,
);
if (deploying && !hasCard) {
// New deployment started but no card exists - reload to show it
window.location.reload();
return;
}
this.currentDeploymentId = data.latestDeploymentID;
}
// Update deploying state based on latest deployment status
if (deploying && !this.isDeploying) {
this.isDeploying = true;
this._scheduleStatusPoll(); // Switch to fast polling
} else if (!deploying && this.isDeploying) {
// Deployment finished - reload to show final state
this.isDeploying = false;
window.location.reload();
}
} catch (err) {
console.error("Status fetch error:", err);
}
},
submitDeploy() {
this.isDeploying = true;
},
formatTime(isoTime) {
return Alpine.store("utils").formatRelativeTime(isoTime);
},
}));
});

View File

@@ -1,143 +0,0 @@
/**
* upaas - Global Utilities Store
*
* Shared formatting, status helpers, and clipboard utilities used across all pages.
*/
document.addEventListener("alpine:init", () => {
Alpine.store("utils", {
/**
* Format a date string as relative time (e.g., "5 minutes ago")
*/
formatRelativeTime(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return "just now";
if (diffMin < 60)
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago");
if (diffHour < 24)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffDay < 7)
return diffDay + (diffDay === 1 ? " day ago" : " days ago");
return date.toLocaleDateString();
},
/**
* Get the badge class for a given status
*/
statusBadgeClass(status) {
if (status === "running" || status === "success") return "badge-success";
if (status === "building" || status === "deploying")
return "badge-warning";
if (status === "failed" || status === "error") return "badge-error";
return "badge-neutral";
},
/**
* Format status for display (capitalize first letter)
*/
statusLabel(status) {
if (!status) return "";
return status.charAt(0).toUpperCase() + status.slice(1);
},
/**
* Check if status indicates active deployment
*/
isDeploying(status) {
return status === "building" || status === "deploying";
},
/**
* Scroll an element to the bottom
*/
scrollToBottom(el) {
if (el) {
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}
},
/**
* Check if a scrollable element is at (or near) the bottom.
* Tolerance of 30px accounts for rounding and partial lines.
*/
isScrolledToBottom(el, tolerance = 30) {
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance;
},
/**
* Copy text to clipboard
*/
async copyToClipboard(text, button) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
document.body.removeChild(textArea);
return true;
} catch (e) {
document.body.removeChild(textArea);
return false;
}
}
},
});
});
// ============================================
// Legacy support - expose utilities globally
// ============================================
window.upaas = {
// These are kept for backwards compatibility but templates should use Alpine.js
formatRelativeTime(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return "just now";
if (diffMin < 60)
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago");
if (diffHour < 24)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffDay < 7)
return diffDay + (diffDay === 1 ? " day ago" : " days ago");
return date.toLocaleDateString();
},
// Placeholder functions - templates should migrate to Alpine.js
initAppDetailPage() {},
initDeploymentsPage() {},
};
// Update relative times on page load for non-Alpine elements
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".relative-time[data-time]").forEach((el) => {
const time = el.getAttribute("data-time");
if (time) {
el.textContent = window.upaas.formatRelativeTime(time);
}
});
});

View File

@@ -15,11 +15,7 @@
</div> </div>
{{template "footer" .}} {{template "footer" .}}
<script defer src="/s/js/alpine.min.js"></script> <script defer src="/s/js/alpine.min.js"></script>
<script src="/s/js/utils.js"></script> <script src="/s/js/app.js"></script>
<script src="/s/js/components.js"></script>
<script src="/s/js/app-detail.js"></script>
<script src="/s/js/deployment.js"></script>
<script src="/s/js/dashboard.js"></script>
</body> </body>
</html> </html>
{{end}} {{end}}

View File

@@ -69,7 +69,7 @@
<a href="/apps/{{.App.ID}}" class="btn-text text-sm py-1 px-2">View</a> <a href="/apps/{{.App.ID}}" class="btn-text text-sm py-1 px-2">View</a>
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary text-sm py-1 px-2">Edit</a> <a href="/apps/{{.App.ID}}/edit" class="btn-secondary text-sm py-1 px-2">Edit</a>
<form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline"> <form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline">
{{ $.CSRFField }} {{ .CSRFField }}
<button type="submit" class="btn-success text-sm py-1 px-2">Deploy</button> <button type="submit" class="btn-success text-sm py-1 px-2">Deploy</button>
</form> </form>
</div> </div>

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>