Compare commits

..

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
114 changed files with 2314 additions and 9478 deletions

View File

@@ -1,10 +0,0 @@
.git
bin/
.editorconfig
.vscode/
.idea/
*.test
LICENSE
CONVENTIONS.md
REPO_POLICIES.md
README.md

View File

@@ -1,15 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.go]
indent_style = tab
[Makefile]
indent_style = tab

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: script/cibuild

31
.gitignore vendored
View File

@@ -1,31 +0,0 @@
# OS
.DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Node
node_modules/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Go
bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out

View File

@@ -1,9 +1,5 @@
version: "2" version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run: run:
timeout: 5m timeout: 5m
modules-download-mode: readonly modules-download-mode: readonly
@@ -18,7 +14,8 @@ linters:
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings:
linters-settings:
lll: lll:
line-length: 88 line-length: 88
funlen: funlen:
@@ -30,5 +27,6 @@ linters:
threshold: 100 threshold: 100
issues: issues:
exclude-use-default: false
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0

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.12.2 (Debian-based), 2026-08-07 FROM golang:1.25-alpine AS builder
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 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 bootstrap setup build lint fmt fmt-check test check clean docker hooks .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")
@@ -7,37 +7,32 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build all: check build
bootstrap:
@script/bootstrap
setup:
@script/setup
build: build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/upaasd go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/upaasd
lint: lint:
@script/lint golangci-lint run --config .golangci.yml ./...
fmt: fmt:
@script/fmt gofmt -s -w .
goimports -w .
fmt-check: npx prettier --write --tab-width 4 static/js/*.js
@script/fmt-check
test: test:
@script/test 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: check:
@script/check @echo "==> Checking formatting..."
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
docker: @echo "==> Running linter..."
@script/docker golangci-lint run --config .golangci.yml ./...
@echo "==> Running tests..."
hooks: go test -v -race ./...
@script/install-precommit @echo "==> Building..."
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/upaasd
@echo "==> All checks passed!"
clean: clean:
rm -rf bin/ rm -rf bin/

View File

@@ -1,15 +1,14 @@
# µPaaS by [@sneak](https://sneak.berlin) # µPaaS by [@sneak](https://sneak.berlin)
A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via webhooks from Gitea, GitHub, or GitLab. A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via Gitea webhooks.
## Features ## Features
- Single admin user with argon2id password hashing - Single admin user with argon2id password hashing
- Per-app SSH keypairs for read-only deploy keys - Per-app SSH keypairs for read-only deploy keys
- Per-app UUID-based webhook URLs with auto-detection of Gitea, GitHub, and GitLab - Per-app UUID-based webhook URLs for Gitea integration
- Branch filtering - only deploy on configured branch changes - Branch filtering - only deploy on configured branch changes
- Environment variables, labels, and volume mounts per app - Environment variables, labels, and volume mounts per app
- CPU and memory resource limits per app
- Docker builds via socket access - Docker builds via socket access
- Notifications via ntfy and Slack-compatible webhooks - Notifications via ntfy and Slack-compatible webhooks
- Simple server-rendered UI with Tailwind CSS - Simple server-rendered UI with Tailwind CSS
@@ -20,7 +19,7 @@ A simple self-hosted PaaS that auto-deploys Docker containers from Git repositor
- Complex CI pipelines - Complex CI pipelines
- Multiple container orchestration - Multiple container orchestration
- SPA/API-first design - SPA/API-first design
- Support for non-push webhook events (e.g. issues, merge requests) - Support for non-Gitea webhooks
## Architecture ## Architecture
@@ -45,7 +44,7 @@ upaas/
│ │ ├── auth/ # Authentication service │ │ ├── auth/ # Authentication service
│ │ ├── deploy/ # Deployment orchestration │ │ ├── deploy/ # Deployment orchestration
│ │ ├── notify/ # Notifications (ntfy, Slack) │ │ ├── notify/ # Notifications (ntfy, Slack)
│ │ └── webhook/ # Webhook processing (Gitea, GitHub, GitLab) │ │ └── webhook/ # Gitea webhook processing
│ └── ssh/ # SSH key generation │ └── ssh/ # SSH key generation
├── static/ # Embedded CSS/JS assets ├── static/ # Embedded CSS/JS assets
└── templates/ # Embedded HTML templates └── templates/ # Embedded HTML templates
@@ -100,31 +99,6 @@ chi Router ──► Middleware Stack ──► Handler
- **Async deployments**: Webhook triggers deploy via goroutine with `context.WithoutCancel()` - **Async deployments**: Webhook triggers deploy via goroutine with `context.WithoutCancel()`
- **Embedded assets**: Templates and static files embedded via `//go:embed` - **Embedded assets**: Templates and static files embedded via `//go:embed`
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call
them. We provide:
- `script/bootstrap` — install all dependencies (idempotent)
- `script/setup` — make a fresh clone ready for development
(bootstrap, then install-precommit)
- `script/projectname` — output the project name ("upaas")
- `script/test` — run the test suite
- `script/lint` — run golangci-lint
- `script/fmt` — format all code (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
runs the checks, so a green build implies a green repo)
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
`script/check`)
- `script/install-precommit` — install the git pre-commit hook that
runs `script/precommit`
## Development ## Development
### Prerequisites ### Prerequisites
@@ -136,16 +110,11 @@ them. We provide:
### Commands ### Commands
```bash ```bash
make bootstrap # Install all dependencies (idempotent)
make setup # Bootstrap + install git pre-commit hook
make fmt # Format code make fmt # Format code
make fmt-check # Check formatting (read-only, fails if unformatted)
make lint # Run comprehensive linting make lint # Run comprehensive linting
make test # Run tests with race detection (30s timeout) make test # Run tests with race detection
make check # Verify everything passes (test, lint, fmt-check) make check # Verify everything passes (lint, test, build, format)
make build # Build binary make build # Build binary
make docker # Build Docker image
make hooks # Install pre-commit hook (runs script/precommit)
``` ```
### Commit Requirements ### Commit Requirements
@@ -188,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 | "" |
@@ -207,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`.

View File

@@ -1,408 +0,0 @@
---
title: Repository Policies
last_modified: 2026-07-06
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs `docker build .`) on push. Since the
Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
testing is not possible in the repo, `script/precommit` may skip `script/test`
and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

49
TODO.md
View File

@@ -1,49 +0,0 @@
# Workflow
* branch (from `main`)
* do the work in Next Step
* move Next Step to the top of Completed Steps
* move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR
* push
# Status
1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. `make check`
is green as of the golangci-lint v2.12.2 update.
# Next Step
Confirm `.gitea/workflows/check.yml` gates merges on `make check` so
main cannot regress.
# Completed Steps
- 2026-08-07: Updated golangci-lint to v2.12.2 (canonical
`.golangci.yml`, `Dockerfile` lint stage pin, `script/bootstrap`
release-archive pins) and fixed all resulting lint findings (noctx,
gosec, goconst, lll, dupl, nolintlint); `make check` green.
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section
- 2026-03-11: Monolithic env var editing with bulk save (#158).
- 2026-03-10: Webhook event history UI page (#164); added missing
Makefile docker and hooks targets plus test timeout (#159);
notification settings passed from create form (#160).
- 2026-03-03: REPO_POLICIES compliance file set added (#155).
- 2026-03-01: Module path changed to sneak.berlin/go/upaas (#143);
Dockerfile split into lint and build stages with forced lint
execution (#152, #154).
- 2026-02-26: 1.0.0 tagged; dashboard CSRFField crash fixed (#146).
- 1.0 audit bug fixes (#120-#125): deferred rollback on commit error,
deployment log size cap, error path rendering, docker-compose bind
mount, domain type refactor.
- CI simplified to docker build only (#130).
- 2025-12-29 onward: core PaaS built out: deploys with real-time build
log streaming, container start/stop/restart and logs, TCP/UDP port
mapping, Alpine.js UI, Slack notifications, ULID app IDs, session
handling.
# Future Steps
- Resume feature work only after main is green.

View File

@@ -4,20 +4,20 @@ package main
import ( import (
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker" "git.eeqj.de/sneak/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/handlers" "git.eeqj.de/sneak/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/healthcheck" "git.eeqj.de/sneak/upaas/internal/healthcheck"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/middleware" "git.eeqj.de/sneak/upaas/internal/middleware"
"sneak.berlin/go/upaas/internal/server" "git.eeqj.de/sneak/upaas/internal/server"
"sneak.berlin/go/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/notify" "git.eeqj.de/sneak/upaas/internal/service/notify"
"sneak.berlin/go/upaas/internal/service/webhook" "git.eeqj.de/sneak/upaas/internal/service/webhook"
_ "github.com/joho/godotenv/autoload" _ "github.com/joho/godotenv/autoload"
) )

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:

4
go.mod
View File

@@ -1,4 +1,4 @@
module sneak.berlin/go/upaas module git.eeqj.de/sneak/upaas
go 1.25 go 1.25
@@ -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

@@ -13,8 +13,8 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
) )
// defaultPort is the default HTTP server port. // defaultPort is the default HTTP server port.
@@ -45,14 +45,13 @@ type Config struct {
Port int Port int
Debug bool Debug bool
DataDir string DataDir string
HostDataDir string // Host path for DataDir (Docker bind mounts in container) HostDataDir string // Host path for DataDir (for Docker bind mounts when running in container)
DockerHost string DockerHost string
SentryDSN string SentryDSN string
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

@@ -14,8 +14,8 @@ import (
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
) )
// dataDirPermissions is the file permission for the data directory. // dataDirPermissions is the file permission for the data directory.
@@ -178,8 +178,7 @@ func HashWebhookSecret(secret string) string {
func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error { func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error {
rows, err := d.database.QueryContext(ctx, rows, err := d.database.QueryContext(ctx,
"SELECT id, webhook_secret FROM apps"+ "SELECT id, webhook_secret FROM apps WHERE webhook_secret_hash = '' AND webhook_secret != ''")
" WHERE webhook_secret_hash = '' AND webhook_secret != ''")
if err != nil { if err != nil {
return fmt.Errorf("querying apps for backfill: %w", err) return fmt.Errorf("querying apps for backfill: %w", err)
} }

View File

@@ -5,7 +5,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
func TestHashWebhookSecret(t *testing.T) { func TestHashWebhookSecret(t *testing.T) {

View File

@@ -2,80 +2,36 @@ package database
import ( import (
"context" "context"
"database/sql"
"embed" "embed"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"log/slog"
"sort" "sort"
"strconv"
"strings" "strings"
) )
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsFS embed.FS var migrationsFS embed.FS
// bootstrapVersion is the migration that creates the schema_migrations func (d *Database) migrate(ctx context.Context) error {
// table itself. It is applied before the normal migration loop. // Create migrations table if not exists
const bootstrapVersion = 0 _, err := d.database.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
// ErrInvalidMigrationFilename indicates a migration filename does not follow version TEXT PRIMARY KEY,
// the expected "<version>.sql" or "<version>_<description>.sql" pattern. applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
var ErrInvalidMigrationFilename = errors.New("invalid migration filename")
// ParseMigrationVersion extracts the numeric version prefix from a migration
// filename. Filenames must follow the pattern "<version>.sql" or
// "<version>_<description>.sql", where version is a zero-padded numeric
// string (e.g. "001", "002"). Returns the version as an integer and an
// error if the filename does not match the expected pattern.
func ParseMigrationVersion(filename string) (int, error) {
name := strings.TrimSuffix(filename, ".sql")
if name == "" || name == filename {
return 0, fmt.Errorf(
"%w: %q has no .sql extension or is empty",
ErrInvalidMigrationFilename, filename,
) )
} `)
// Split on underscore to separate version from description.
// If there's no underscore, the entire stem is the version.
versionStr, _, _ := strings.Cut(name, "_")
if versionStr == "" {
return 0, fmt.Errorf(
"%w: %q has empty version prefix",
ErrInvalidMigrationFilename, filename,
)
}
// Validate the version is purely numeric.
for _, ch := range versionStr {
if ch < '0' || ch > '9' {
return 0, fmt.Errorf(
"%w: %q version %q contains non-numeric character %q",
ErrInvalidMigrationFilename, filename, versionStr, string(ch),
)
}
}
version, err := strconv.Atoi(versionStr)
if err != nil { if err != nil {
return 0, fmt.Errorf("%w: %q: %w", ErrInvalidMigrationFilename, filename, err) return fmt.Errorf("failed to create migrations table: %w", err)
} }
return version, nil // Get list of migration files
}
// collectMigrations reads the embedded migrations directory and returns
// migration filenames sorted lexicographically.
func collectMigrations() ([]string, error) {
entries, err := fs.ReadDir(migrationsFS, "migrations") entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read migrations directory: %w", err) return fmt.Errorf("failed to read migrations directory: %w", err)
} }
var migrations []string // Sort migrations by name
migrations := make([]string, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
@@ -85,118 +41,54 @@ func collectMigrations() ([]string, error) {
sort.Strings(migrations) sort.Strings(migrations)
return migrations, nil // Apply each migration
}
// bootstrapMigrationsTable ensures the schema_migrations table exists by
// applying 000_migration.sql if the table is missing.
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
var tableExists int
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
).Scan(&tableExists)
if err != nil {
return fmt.Errorf("failed to check for migrations table: %w", err)
}
if tableExists == 0 {
return applyBootstrapMigration(ctx, db, log)
}
return nil
}
// applyBootstrapMigration reads and executes 000_migration.sql to create the
// schema_migrations table on a fresh database.
func applyBootstrapMigration(ctx context.Context, db *sql.DB, log *slog.Logger) error {
content, err := migrationsFS.ReadFile("migrations/000_migration.sql")
if err != nil {
return fmt.Errorf("failed to read bootstrap migration 000_migration.sql: %w", err)
}
if log != nil {
log.Info("applying bootstrap migration", "version", bootstrapVersion)
}
_, err = db.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
}
return nil
}
// ApplyMigrations applies all pending migrations to db. An optional logger
// may be provided for informational output; pass nil for silent operation.
// This is exported so tests can apply the real schema without the full fx
// lifecycle.
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
bootstrapErr := bootstrapMigrationsTable(ctx, db, log)
if bootstrapErr != nil {
return bootstrapErr
}
migrations, err := collectMigrations()
if err != nil {
return err
}
for _, migration := range migrations { for _, migration := range migrations {
version, parseErr := ParseMigrationVersion(migration) applied, err := d.isMigrationApplied(ctx, migration)
if parseErr != nil {
return parseErr
}
// Check if already applied.
var count int
err = db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version,
).Scan(&count)
if err != nil { if err != nil {
return fmt.Errorf("failed to check migration %s: %w", migration, err) return fmt.Errorf("failed to check migration %s: %w", migration, err)
} }
if count > 0 { if applied {
if log != nil { d.log.Debug("migration already applied", "migration", migration)
log.Debug("migration already applied", "version", version)
}
continue continue
} }
// Apply migration in a transaction. err = d.applyMigration(ctx, migration)
applyErr := applyMigrationTx(ctx, db, migration, version) if err != nil {
if applyErr != nil { return fmt.Errorf("failed to apply migration %s: %w", migration, err)
return applyErr
} }
if log != nil { d.log.Info("migration applied", "migration", migration)
log.Info("migration applied", "version", version)
}
} }
return nil return nil
} }
// applyMigrationTx reads and executes a migration file within a transaction, func (d *Database) isMigrationApplied(ctx context.Context, version string) (bool, error) {
// recording the version in schema_migrations on success. var count int
func applyMigrationTx(
ctx context.Context, err := d.database.QueryRowContext(
db *sql.DB, ctx,
filename string, "SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version int, version,
) error { ).Scan(&count)
content, err := migrationsFS.ReadFile("migrations/" + filename)
if err != nil { if err != nil {
return fmt.Errorf("failed to read migration %s: %w", filename, err) return false, fmt.Errorf("failed to query migration status: %w", err)
} }
transaction, err := db.BeginTx(ctx, nil) return count > 0, nil
}
func (d *Database) applyMigration(ctx context.Context, filename string) error {
content, err := migrationsFS.ReadFile("migrations/" + filename)
if err != nil { if err != nil {
return fmt.Errorf("failed to begin transaction for migration %s: %w", filename, err) return fmt.Errorf("failed to read migration file: %w", err)
}
transaction, err := d.database.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
} }
defer func() { defer func() {
@@ -205,27 +97,26 @@ func applyMigrationTx(
} }
}() }()
// Execute migration
_, err = transaction.ExecContext(ctx, string(content)) _, err = transaction.ExecContext(ctx, string(content))
if err != nil { if err != nil {
return fmt.Errorf("failed to execute migration %s: %w", filename, err) return fmt.Errorf("failed to execute migration: %w", err)
} }
_, err = transaction.ExecContext(ctx, // Record migration
_, err = transaction.ExecContext(
ctx,
"INSERT INTO schema_migrations (version) VALUES (?)", "INSERT INTO schema_migrations (version) VALUES (?)",
version, filename,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to record migration %s: %w", filename, 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 %s: %w", filename, err) return fmt.Errorf("failed to commit migration: %w", commitErr)
} }
return nil return nil
} }
func (d *Database) migrate(ctx context.Context) error {
return ApplyMigrations(ctx, d.database, d.log)
}

View File

@@ -1,9 +0,0 @@
-- Migration 000: Schema migrations tracking table
-- Applied as a bootstrap step before the normal migration loop.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);

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,3 +0,0 @@
-- Add CPU and memory resource limits per app
ALTER TABLE apps ADD COLUMN cpu_limit REAL;
ALTER TABLE apps ADD COLUMN memory_limit INTEGER;

View File

@@ -1,117 +0,0 @@
package database_test
import (
"context"
"database/sql"
"testing"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/database"
)
func TestParseMigrationVersion(t *testing.T) {
t.Parallel()
tests := []struct {
filename string
wantVersion int
wantErr bool
}{
{filename: "000_migration.sql", wantVersion: 0},
{filename: "001_initial.sql", wantVersion: 1},
{filename: "002_remove_container_id.sql", wantVersion: 2},
{filename: "007_add_resource_limits.sql", wantVersion: 7},
{filename: "100_large_version.sql", wantVersion: 100},
{filename: ".sql", wantErr: true},
{filename: "_foo.sql", wantErr: true},
{filename: "abc_foo.sql", wantErr: true},
{filename: "1a2_bad.sql", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.filename, func(t *testing.T) {
t.Parallel()
version, err := database.ParseMigrationVersion(tt.filename)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantVersion, version)
})
}
}
func TestApplyMigrationsFreshDatabase(t *testing.T) {
t.Parallel()
db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on")
require.NoError(t, err)
defer func() { _ = db.Close() }()
ctx := context.Background()
err = database.ApplyMigrations(ctx, db, nil)
require.NoError(t, err)
// Verify schema_migrations table exists with INTEGER version column.
var version int
err = db.QueryRowContext(ctx,
"SELECT version FROM schema_migrations WHERE version = 0",
).Scan(&version)
require.NoError(t, err)
assert.Equal(t, 0, version)
// Verify that all migrations were recorded.
var count int
err = db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations",
).Scan(&count)
require.NoError(t, err)
// 000 bootstrap + 001 through 007 = 8 entries.
assert.Equal(t, 8, count)
// Verify application tables were created by the migrations.
var tableCount int
err = db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'",
).Scan(&tableCount)
require.NoError(t, err)
assert.Equal(t, 1, tableCount)
}
func TestApplyMigrationsIdempotent(t *testing.T) {
t.Parallel()
db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on")
require.NoError(t, err)
defer func() { _ = db.Close() }()
ctx := context.Background()
// Apply twice — second run should be a no-op.
err = database.ApplyMigrations(ctx, db, nil)
require.NoError(t, err)
err = database.ApplyMigrations(ctx, db, nil)
require.NoError(t, err)
var count int
err = db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations",
).Scan(&count)
require.NoError(t, err)
assert.Equal(t, 8, count)
}

View File

@@ -1,41 +0,0 @@
package database
import (
"log/slog"
"os"
"testing"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/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"
@@ -25,9 +24,8 @@ import (
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/logger"
) )
// sshKeyPermissions is the file permission for SSH private keys. // sshKeyPermissions is the file permission for SSH private keys.
@@ -41,8 +39,7 @@ const stopTimeoutSeconds = 10
// gitImage is the Docker image used for git operations. // gitImage is the Docker image used for git operations.
// alpine/git v2.47.2 - pulled 2025-12-30 // alpine/git v2.47.2 - pulled 2025-12-30
const gitImage = "alpine/git@sha256:" + const gitImage = "alpine/git@sha256:d86f367afb53d022acc4377741e7334bc20add161bb10234272b91b459b4b7d8"
"d86f367afb53d022acc4377741e7334bc20add161bb10234272b91b459b4b7d8"
// ErrNotConnected is returned when Docker client is not connected. // ErrNotConnected is returned when Docker client is not connected.
var ErrNotConnected = errors.New("docker client not connected") var ErrNotConnected = errors.New("docker client not connected")
@@ -118,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
} }
@@ -146,8 +143,6 @@ type CreateContainerOptions struct {
Volumes []VolumeMount Volumes []VolumeMount
Ports []PortMapping Ports []PortMapping
Network string Network string
CPULimit float64 // CPU cores (0.5 = half a core). 0 means unlimited.
MemoryLimit int64 // Memory in bytes. 0 means unlimited.
} }
// VolumeMount represents a volume mount. // VolumeMount represents a volume mount.
@@ -164,14 +159,6 @@ type PortMapping struct {
Protocol string // "tcp" or "udp" Protocol string // "tcp" or "udp"
} }
// nanoCPUsPerCPU is the number of NanoCPUs per CPU core.
const nanoCPUsPerCPU = 1e9
// cpuLimitToNanoCPUs converts a CPU limit (e.g. 0.5 cores) to Docker NanoCPUs.
func cpuLimitToNanoCPUs(cpuLimit float64) int64 {
return int64(cpuLimit * nanoCPUsPerCPU)
}
// buildPortConfig converts port mappings to Docker port configuration. // buildPortConfig converts port mappings to Docker port configuration.
func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) { func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
exposedPorts := make(nat.PortSet) exposedPorts := make(nat.PortSet)
@@ -196,22 +183,28 @@ func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
return exposedPorts, portBindings return exposedPorts, portBindings
} }
// buildEnvSlice converts an env map to a Docker-compatible env slice. // CreateContainer creates a new container.
func buildEnvSlice(env map[string]string) []string { func (c *Client) CreateContainer(
envSlice := make([]string, 0, len(env)) ctx context.Context,
opts CreateContainerOptions,
) (string, error) {
if c.docker == nil {
return "", ErrNotConnected
}
for key, val := range env { c.log.Info("creating container", "name", opts.Name, "image", opts.Image)
// Convert env map to slice
envSlice := make([]string, 0, len(opts.Env))
for key, val := range opts.Env {
envSlice = append(envSlice, key+"="+val) envSlice = append(envSlice, key+"="+val)
} }
return envSlice // Convert volumes to mounts
} mounts := make([]mount.Mount, 0, len(opts.Volumes))
// buildMounts converts volume mounts to Docker mount configuration. for _, vol := range opts.Volumes {
func buildMounts(volumes []VolumeMount) []mount.Mount {
mounts := make([]mount.Mount, 0, len(volumes))
for _, vol := range volumes {
mounts = append(mounts, mount.Mount{ mounts = append(mounts, mount.Mount{
Type: mount.TypeBind, Type: mount.TypeBind,
Source: vol.HostPath, Source: vol.HostPath,
@@ -220,49 +213,21 @@ func buildMounts(volumes []VolumeMount) []mount.Mount {
}) })
} }
return mounts // Convert ports to exposed ports and port bindings
}
// buildResources builds Docker resource constraints from container options.
func buildResources(opts CreateContainerOptions) container.Resources {
resources := container.Resources{}
if opts.CPULimit > 0 {
resources.NanoCPUs = cpuLimitToNanoCPUs(opts.CPULimit)
}
if opts.MemoryLimit > 0 {
resources.Memory = opts.MemoryLimit
}
return resources
}
// CreateContainer creates a new container.
func (c *Client) CreateContainer(
ctx context.Context,
opts CreateContainerOptions,
) (ContainerID, error) {
if c.docker == nil {
return "", ErrNotConnected
}
c.log.Info("creating container", "name", opts.Name, "image", opts.Image)
exposedPorts, portBindings := buildPortConfig(opts.Ports) exposedPorts, portBindings := buildPortConfig(opts.Ports)
// Create container
resp, err := c.docker.ContainerCreate(ctx, resp, err := c.docker.ContainerCreate(ctx,
&container.Config{ &container.Config{
Image: opts.Image, Image: opts.Image,
Env: buildEnvSlice(opts.Env), Env: envSlice,
Labels: opts.Labels, Labels: opts.Labels,
ExposedPorts: exposedPorts, ExposedPorts: exposedPorts,
}, },
&container.HostConfig{ &container.HostConfig{
Mounts: buildMounts(opts.Volumes), Mounts: mounts,
PortBindings: portBindings, PortBindings: portBindings,
NetworkMode: container.NetworkMode(opts.Network), NetworkMode: container.NetworkMode(opts.Network),
Resources: buildResources(opts),
RestartPolicy: container.RestartPolicy{ RestartPolicy: container.RestartPolicy{
Name: container.RestartPolicyUnlessStopped, Name: container.RestartPolicyUnlessStopped,
}, },
@@ -275,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)
} }
@@ -295,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
} }
@@ -304,11 +269,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) err
timeout := stopTimeoutSeconds timeout := stopTimeoutSeconds
err := c.docker.ContainerStop( err := c.docker.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
ctx,
containerID.String(),
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)
} }
@@ -319,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 {
@@ -328,11 +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( err := c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
ctx,
containerID.String(),
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)
} }
@@ -343,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 {
@@ -356,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)
} }
@@ -379,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)
} }
@@ -396,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)
} }
@@ -420,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
} }
@@ -455,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
} }
@@ -478,8 +435,7 @@ type CloneResult struct {
CommitSHA string // The HEAD commit SHA after clone/checkout CommitSHA string // The HEAD commit SHA after clone/checkout
} }
// CloneRepo clones a git repository using SSH and optionally checks out a // CloneRepo clones a git repository using SSH and optionally checks out a specific commit.
// specific commit.
// containerDir is the path inside the upaas container (for writing files). // containerDir is the path inside the upaas container (for writing files).
// hostDir is the corresponding path on the Docker host (for bind mounts). // hostDir is the corresponding path on the Docker host (for bind mounts).
// If commitSHA is provided, that specific commit will be checked out. // If commitSHA is provided, that specific commit will be checked out.
@@ -523,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 {
@@ -555,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,
@@ -585,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
@@ -594,13 +536,11 @@ func (c *Client) performBuild(
// scannerInitialBufferSize is the initial buffer size for the build log scanner. // scannerInitialBufferSize is the initial buffer size for the build log scanner.
const scannerInitialBufferSize = 64 * 1024 // 64KB const scannerInitialBufferSize = 64 * 1024 // 64KB
// scannerMaxBufferSize is the max buffer size for build log lines // scannerMaxBufferSize is the max buffer size for build log lines (base64 layers can be large).
// (base64 layers can be large).
const scannerMaxBufferSize = 1024 * 1024 // 1MB const scannerMaxBufferSize = 1024 * 1024 // 1MB
// streamBuildOutput reads Docker build output line by line and writes to // streamBuildOutput reads Docker build output line by line and writes to stdout and optional log writer.
// stdout and optional log writer. Docker sends newline-delimited JSON, so // Docker sends newline-delimited JSON, so reading line by line ensures each log entry is written immediately.
// reading line by line ensures each log entry is written immediately.
func (c *Client) streamBuildOutput(body io.Reader, logWriter io.Writer) error { func (c *Client) streamBuildOutput(body io.Reader, logWriter io.Writer) error {
scanner := bufio.NewScanner(body) scanner := bufio.NewScanner(body)
buf := make([]byte, 0, scannerInitialBufferSize) buf := make([]byte, 0, scannerInitialBufferSize)
@@ -628,10 +568,7 @@ func (c *Client) streamBuildOutput(body io.Reader, logWriter io.Writer) error {
return nil return nil
} }
func (c *Client) performClone( func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResult, error) {
ctx context.Context,
cfg *cloneConfig,
) (*CloneResult, error) {
// Create work directory for clone destination // Create work directory for clone destination
err := os.MkdirAll(cfg.containerDir, workDirPermissions) err := os.MkdirAll(cfg.containerDir, workDirPermissions)
if err != nil { if err != nil {
@@ -651,26 +588,22 @@ func (c *Client) performClone(
} }
}() }()
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( _ = c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true})
ctx,
gitContainerID.String(),
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.
@@ -699,8 +632,7 @@ func (c *Client) createGitContainer(
entrypoint := []string{} entrypoint := []string{}
cmd := []string{"sh", "-c", script} cmd := []string{"sh", "-c", script}
// Use host paths for Docker bind mounts // Use host paths for Docker bind mounts (Docker runs on the host, not in our container)
// (Docker runs on the host, not in our container)
resp, err := c.docker.ContainerCreate(ctx, resp, err := c.docker.ContainerCreate(ctx,
&container.Config{ &container.Config{
Image: gitImage, Image: gitImage,
@@ -728,23 +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( func (c *Client) runGitClone(ctx context.Context, containerID string) (*CloneResult, error) {
ctx context.Context, err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
containerID ContainerID,
) (*CloneResult, error) {
err := c.docker.ContainerStart(ctx, containerID.String(), 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( statusCh, errCh := c.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
ctx,
containerID.String(),
container.WaitConditionNotRunning,
)
select { select {
case err := <-errCh: case err := <-errCh:

View File

@@ -1,31 +0,0 @@
package docker //nolint:testpackage // tests unexported cpuLimitToNanoCPUs
import (
"testing"
)
func TestCpuLimitToNanoCPUs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cpuLimit float64
expected int64
}{
{"one core", 1.0, 1_000_000_000},
{"half core", 0.5, 500_000_000},
{"two cores", 2.0, 2_000_000_000},
{"quarter core", 0.25, 250_000_000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := cpuLimitToNanoCPUs(tt.cpuLimit)
if got != tt.expected {
t.Errorf("cpuLimitToNanoCPUs(%v) = %d, want %d", tt.cpuLimit, got, tt.expected)
}
})
}
}

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

@@ -6,14 +6,11 @@ import (
"testing" "testing"
) )
// mainBranch is the branch name used across validation tests.
const mainBranch = "main"
func TestValidBranchRegex(t *testing.T) { func TestValidBranchRegex(t *testing.T) {
t.Parallel() t.Parallel()
valid := []string{ valid := []string{
mainBranch, "main",
"develop", "develop",
"feature/my-feature", "feature/my-feature",
"release-1.0", "release-1.0",
@@ -73,7 +70,7 @@ func TestValidCommitSHARegex(t *testing.T) {
} }
} }
func TestCloneRepoRejectsInjection(t *testing.T) { func TestCloneRepoRejectsInjection(t *testing.T) { //nolint:funlen // table-driven test
t.Parallel() t.Parallel()
c := &Client{ c := &Client{
@@ -103,25 +100,25 @@ func TestCloneRepoRejectsInjection(t *testing.T) {
}, },
{ {
name: "injection in commitSHA", name: "injection in commitSHA",
branch: mainBranch, branch: "main",
commitSHA: "not-a-sha; rm -rf /", commitSHA: "not-a-sha; rm -rf /",
wantErr: ErrInvalidCommitSHA, wantErr: ErrInvalidCommitSHA,
}, },
{ {
name: "short SHA rejected", name: "short SHA rejected",
branch: mainBranch, branch: "main",
commitSHA: "abc123", commitSHA: "abc123",
wantErr: ErrInvalidCommitSHA, wantErr: ErrInvalidCommitSHA,
}, },
{ {
name: "valid inputs pass validation (hit NotConnected)", name: "valid inputs pass validation (hit NotConnected)",
branch: mainBranch, branch: "main",
commitSHA: "abc123def456789012345678901234567890abcd", commitSHA: "abc123def456789012345678901234567890abcd",
wantErr: ErrNotConnected, wantErr: ErrNotConnected,
}, },
{ {
name: "valid branch no SHA passes validation (hit NotConnected)", name: "valid branch no SHA passes validation (hit NotConnected)",
branch: mainBranch, branch: "main",
wantErr: ErrNotConnected, wantErr: ErrNotConnected,
}, },
} }

View File

@@ -7,7 +7,8 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/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,38 +74,40 @@ 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 {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{jsonKeyError: "invalid JSON body"}, map[string]string{"error": "invalid JSON body"},
http.StatusBadRequest) http.StatusBadRequest)
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{jsonKeyError: "username and password are required"}, map[string]string{"error": "username and password are required"},
http.StatusBadRequest) http.StatusBadRequest)
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{jsonKeyError: "invalid credentials"}, map[string]string{"error": "invalid credentials"},
http.StatusUnauthorized) http.StatusUnauthorized)
return return
@@ -114,7 +117,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
if sessionErr != nil { if sessionErr != nil {
h.log.Error("api: failed to create session", "error", sessionErr) h.log.Error("api: failed to create session", "error", sessionErr)
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{jsonKeyError: "failed to create session"}, map[string]string{"error": "failed to create session"},
http.StatusInternalServerError) http.StatusInternalServerError)
return return
@@ -133,7 +136,7 @@ func (h *Handlers) HandleAPIListApps() http.HandlerFunc {
apps, err := h.appService.ListApps(request.Context()) apps, err := h.appService.ListApps(request.Context())
if err != nil { if err != nil {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{jsonKeyError: "failed to list apps"}, map[string]string{"error": "failed to list apps"},
http.StatusInternalServerError) http.StatusInternalServerError)
return return
@@ -156,7 +159,7 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
application, err := h.appService.GetApp(request.Context(), appID) application, err := h.appService.GetApp(request.Context(), appID)
if err != nil { if err != nil {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{jsonKeyError: "internal server error"}, map[string]string{"error": "internal server error"},
http.StatusInternalServerError) http.StatusInternalServerError)
return return
@@ -164,7 +167,7 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
if application == nil { if application == nil {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{jsonKeyError: "app not found"}, map[string]string{"error": "app not found"},
http.StatusNotFound) http.StatusNotFound)
return return
@@ -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
@@ -185,7 +288,7 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
application, err := h.appService.GetApp(request.Context(), appID) application, err := h.appService.GetApp(request.Context(), appID)
if err != nil || application == nil { if err != nil || application == nil {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{jsonKeyError: "app not found"}, map[string]string{"error": "app not found"},
http.StatusNotFound) http.StatusNotFound)
return return
@@ -205,7 +308,7 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
) )
if deployErr != nil { if deployErr != nil {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{jsonKeyError: "failed to list deployments"}, map[string]string{"error": "failed to list deployments"},
http.StatusInternalServerError) http.StatusInternalServerError)
return return
@@ -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 {
@@ -231,7 +363,7 @@ func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
user, err := h.auth.GetCurrentUser(request.Context(), request) user, err := h.auth.GetCurrentUser(request.Context(), request)
if err != nil || user == nil { if err != nil || user == nil {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{jsonKeyError: "unauthorized"}, map[string]string{"error": "unauthorized"},
http.StatusUnauthorized) http.StatusUnauthorized)
return return

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"
"sneak.berlin/go/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())
}) })
}) })
@@ -47,12 +48,7 @@ func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
r := apiRouter(tc) r := apiRouter(tc)
loginBody := `{"username":"admin","password":"password123"}` loginBody := `{"username":"admin","password":"password123"}`
req := httptest.NewRequestWithContext( req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(loginBody))
t.Context(),
http.MethodPost,
"/api/v1/login",
strings.NewReader(loginBody),
)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@@ -66,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.NewRequestWithContext(t.Context(), 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)
@@ -100,12 +103,7 @@ func TestAPILoginSuccess(t *testing.T) {
r := apiRouter(tc) r := apiRouter(tc)
body := `{"username":"admin","password":"password123"}` body := `{"username":"admin","password":"password123"}`
req := httptest.NewRequestWithContext( req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
t.Context(),
http.MethodPost,
"/api/v1/login",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@@ -132,12 +130,7 @@ func TestAPILoginInvalidCredentials(t *testing.T) {
r := apiRouter(tc) r := apiRouter(tc)
body := `{"username":"admin","password":"wrong"}` body := `{"username":"admin","password":"wrong"}`
req := httptest.NewRequestWithContext( req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
t.Context(),
http.MethodPost,
"/api/v1/login",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@@ -154,12 +147,7 @@ func TestAPILoginMissingFields(t *testing.T) {
r := apiRouter(tc) r := apiRouter(tc)
body := `{"username":"","password":""}` body := `{"username":"","password":""}`
req := httptest.NewRequestWithContext( req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
t.Context(),
http.MethodPost,
"/api/v1/login",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@@ -175,9 +163,7 @@ func TestAPIRejectsUnauthenticated(t *testing.T) {
r := apiRouter(tc) r := apiRouter(tc)
req := httptest.NewRequestWithContext( req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
t.Context(), http.MethodGet, "/api/v1/apps", nil,
)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
r.ServeHTTP(rr, req) r.ServeHTTP(rr, req)
@@ -189,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
@@ -202,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
@@ -210,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) {
@@ -234,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)
} }
@@ -243,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

@@ -7,7 +7,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -16,10 +15,9 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/templates"
"sneak.berlin/go/upaas/templates"
) )
const ( const (
@@ -29,23 +27,6 @@ const (
deploymentsHistoryLimit = 50 deploymentsHistoryLimit = 50
) )
// redirectToApp issues a SeeOther redirect to the page for the given
// app ID, with an optional suffix such as "/deployments" or
// "?success=updated". The ID is path-escaped so the target is always
// a relative application URL.
func redirectToApp(
writer http.ResponseWriter,
request *http.Request,
appID, suffix string,
) {
http.Redirect(
writer,
request,
"/apps/"+url.PathEscape(appID)+suffix,
http.StatusSeeOther,
)
}
// HandleAppNew returns the new app form handler. // HandleAppNew returns the new app form handler.
func (h *Handlers) HandleAppNew() http.HandlerFunc { func (h *Handlers) HandleAppNew() http.HandlerFunc {
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
@@ -58,9 +39,7 @@ func (h *Handlers) HandleAppNew() http.HandlerFunc {
} }
// HandleAppCreate handles app creation. // HandleAppCreate handles app creation.
// func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
//nolint:funlen // validation adds necessary length
func (h *Handlers) HandleAppCreate() http.HandlerFunc {
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@@ -75,18 +54,12 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc {
repoURL := request.FormValue("repo_url") repoURL := request.FormValue("repo_url")
branch := request.FormValue("branch") branch := request.FormValue("branch")
dockerfilePath := request.FormValue("dockerfile_path") dockerfilePath := request.FormValue("dockerfile_path")
dockerNetwork := request.FormValue("docker_network")
ntfyTopic := request.FormValue("ntfy_topic")
slackWebhook := request.FormValue("slack_webhook")
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"Name": name, "Name": name,
"RepoURL": repoURL, "RepoURL": repoURL,
"Branch": branch, "Branch": branch,
"DockerfilePath": dockerfilePath, "DockerfilePath": dockerfilePath,
"DockerNetwork": dockerNetwork,
"NtfyTopic": ntfyTopic,
"SlackWebhook": slackWebhook,
}, request) }, request)
if name == "" || repoURL == "" { if name == "" || repoURL == "" {
@@ -99,15 +72,7 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc {
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
} }
@@ -127,9 +92,6 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc {
RepoURL: repoURL, RepoURL: repoURL,
Branch: branch, Branch: branch,
DockerfilePath: dockerfilePath, DockerfilePath: dockerfilePath,
DockerNetwork: dockerNetwork,
NtfyTopic: ntfyTopic,
SlackWebhook: slackWebhook,
}, },
) )
if createErr != nil { if createErr != nil {
@@ -181,14 +143,10 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
} }
webhookURL := "https://" + request.Host + "/webhook/" + application.WebhookSecret webhookURL := "https://" + request.Host + "/webhook/" + application.WebhookSecret
deployKey := formatDeployKey( deployKey := formatDeployKey(application.SSHPublicKey, application.CreatedAt, application.Name)
application.SSHPublicKey,
application.CreatedAt,
application.Name,
)
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
dataKeyApp: application, "App": application,
"EnvVars": envVars, "EnvVars": envVars,
"Labels": labels, "Labels": labels,
"Volumes": volumes, "Volumes": volumes,
@@ -226,7 +184,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
} }
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
dataKeyApp: application, "App": application,
}, request) }, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data) h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -234,7 +192,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
} }
// HandleAppUpdate handles app updates. // HandleAppUpdate handles app updates.
func (h *Handlers) HandleAppUpdate() http.HandlerFunc { func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@@ -259,21 +217,10 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
nameErr := validateAppName(newName) nameErr := validateAppName(newName)
if nameErr != nil { if nameErr != nil {
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
dataKeyApp: application, "App": application,
dataKeyError: "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{
dataKeyApp: application,
dataKeyError: "Invalid repository URL: " + repoURLErr.Error(),
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
return return
} }
@@ -282,19 +229,23 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
application.RepoURL = request.FormValue("repo_url") application.RepoURL = request.FormValue("repo_url")
application.Branch = request.FormValue("branch") application.Branch = request.FormValue("branch")
application.DockerfilePath = request.FormValue("dockerfile_path") application.DockerfilePath = request.FormValue("dockerfile_path")
application.DockerNetwork = optionalNullString(request.FormValue("docker_network"))
application.NtfyTopic = optionalNullString(request.FormValue("ntfy_topic"))
application.SlackWebhook = optionalNullString(request.FormValue("slack_webhook"))
limitsErr := applyResourceLimits(application, request) if network := request.FormValue("docker_network"); network != "" {
if limitsErr != "" { application.DockerNetwork = sql.NullString{String: network, Valid: true}
data := h.addGlobals(map[string]any{ } else {
dataKeyApp: application, application.DockerNetwork = sql.NullString{}
dataKeyError: limitsErr, }
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
return if ntfy := request.FormValue("ntfy_topic"); ntfy != "" {
application.NtfyTopic = sql.NullString{String: ntfy, Valid: true}
} else {
application.NtfyTopic = sql.NullString{}
}
if slack := request.FormValue("slack_webhook"); slack != "" {
application.SlackWebhook = sql.NullString{String: slack, Valid: true}
} else {
application.SlackWebhook = sql.NullString{}
} }
saveErr := application.Save(request.Context()) saveErr := application.Save(request.Context())
@@ -302,15 +253,16 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
h.log.Error("failed to update app", "error", saveErr) h.log.Error("failed to update app", "error", saveErr)
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
dataKeyApp: application, "App": application,
dataKeyError: "Failed to update app", "Error": "Failed to update app",
}, request) }, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data) h.renderTemplate(writer, tmpl, "app_edit.html", data)
return return
} }
redirectToApp(writer, request, application.ID, "?success=updated") redirectURL := "/apps/" + application.ID + "?success=updated"
http.Redirect(writer, request, redirectURL, http.StatusSeeOther)
} }
} }
@@ -395,7 +347,12 @@ func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
} }
}(deployCtx, application) }(deployCtx, application)
redirectToApp(writer, request, application.ID, "/deployments") http.Redirect(
writer,
request,
"/apps/"+application.ID+"/deployments",
http.StatusSeeOther,
)
} }
} }
@@ -416,7 +373,12 @@ func (h *Handlers) HandleCancelDeploy() http.HandlerFunc {
h.log.Info("deployment cancelled by user", "app", application.Name) h.log.Info("deployment cancelled by user", "app", application.Name)
} }
redirectToApp(writer, request, application.ID, "") http.Redirect(
writer,
request,
"/apps/"+application.ID,
http.StatusSeeOther,
)
} }
} }
@@ -435,12 +397,12 @@ func (h *Handlers) HandleAppRollback() http.HandlerFunc {
rollbackErr := h.deploy.Rollback(request.Context(), application) rollbackErr := h.deploy.Rollback(request.Context(), application)
if rollbackErr != nil { if rollbackErr != nil {
h.log.Error("rollback failed", "error", rollbackErr, "app", application.Name) h.log.Error("rollback failed", "error", rollbackErr, "app", application.Name)
redirectToApp(writer, request, application.ID, "") http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return return
} }
redirectToApp(writer, request, application.ID, "?success=rolledback") http.Redirect(writer, request, "/apps/"+application.ID+"?success=rolledback", http.StatusSeeOther)
} }
} }
@@ -464,7 +426,7 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
) )
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
dataKeyApp: application, "App": application,
"Deployments": deployments, "Deployments": deployments,
}, request) }, request)
@@ -537,7 +499,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return return
} }
_, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- output sanitized _, _ = writer.Write([]byte(logs))
} }
} }
@@ -572,12 +534,12 @@ 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{
jsonKeyLogs: logs, "logs": logs,
jsonKeyStatus: deployment.Status, "status": deployment.Status,
} }
_ = json.NewEncoder(writer).Encode(response) _ = json.NewEncoder(writer).Encode(response)
@@ -619,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 -- internal path, not user input _, err := os.Stat(logPath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
http.NotFound(writer, request) http.NotFound(writer, request)
@@ -640,7 +602,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
writer.Header().Set("Content-Type", "text/plain; charset=utf-8") writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
http.ServeFile(writer, request, logPath) // #nosec G703 -- internal path http.ServeFile(writer, request, logPath)
} }
} }
@@ -664,8 +626,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID) containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID)
if containerErr != nil || containerInfo == nil { if containerErr != nil || containerInfo == nil {
response := map[string]any{ response := map[string]any{
jsonKeyLogs: "No container running\n", "logs": "No container running\n",
jsonKeyStatus: "stopped", "status": "stopped",
} }
_ = json.NewEncoder(writer).Encode(response) _ = json.NewEncoder(writer).Encode(response)
@@ -685,8 +647,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
) )
response := map[string]any{ response := map[string]any{
jsonKeyLogs: "Failed to fetch container logs\n", "logs": "Failed to fetch container logs\n",
jsonKeyStatus: "error", "status": "error",
} }
_ = json.NewEncoder(writer).Encode(response) _ = json.NewEncoder(writer).Encode(response)
@@ -699,8 +661,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
} }
response := map[string]any{ response := map[string]any{
jsonKeyLogs: SanitizeLogs(logs), "logs": logs,
jsonKeyStatus: status, "status": status,
} }
_ = json.NewEncoder(writer).Encode(response) _ = json.NewEncoder(writer).Encode(response)
@@ -734,7 +696,7 @@ func (h *Handlers) HandleAppStatusAPI() http.HandlerFunc {
} }
response := map[string]any{ response := map[string]any{
jsonKeyStatus: string(application.Status), "status": string(application.Status),
"latestDeploymentID": latestDeploymentID, "latestDeploymentID": latestDeploymentID,
"latestDeploymentStatus": latestDeploymentStatus, "latestDeploymentStatus": latestDeploymentStatus,
} }
@@ -771,7 +733,7 @@ func (h *Handlers) HandleRecentDeploymentsAPI() http.HandlerFunc {
for _, d := range deployments { for _, d := range deployments {
deploymentsData = append(deploymentsData, map[string]any{ deploymentsData = append(deploymentsData, map[string]any{
"id": d.ID, "id": d.ID,
jsonKeyStatus: string(d.Status), "status": string(d.Status),
"duration": d.Duration(), "duration": d.Duration(),
"shortCommit": d.ShortCommit(), "shortCommit": d.ShortCommit(),
"finishedAtISO": d.FinishedAtISO(), "finishedAtISO": d.FinishedAtISO(),
@@ -813,7 +775,7 @@ func (h *Handlers) handleContainerAction(
containerInfo, containerErr := h.docker.FindContainerByAppID(ctx, appID) containerInfo, containerErr := h.docker.FindContainerByAppID(ctx, appID)
if containerErr != nil || containerInfo == nil { if containerErr != nil || containerInfo == nil {
redirectToApp(writer, request, appID, "") http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return return
} }
@@ -846,7 +808,7 @@ func (h *Handlers) handleContainerAction(
"action", action, "app", application.Name, "container", containerID) "action", action, "app", application.Name, "container", containerID)
} }
redirectToApp(writer, request, appID, "") http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
} }
// HandleAppRestart handles restarting an app's container. // HandleAppRestart handles restarting an app's container.
@@ -900,7 +862,7 @@ func (h *Handlers) addKeyValueToApp(
value := request.FormValue("value") value := request.FormValue("value")
if key == "" || value == "" { if key == "" || value == "" {
redirectToApp(writer, request, application.ID, "") http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return return
} }
@@ -910,95 +872,53 @@ func (h *Handlers) addKeyValueToApp(
h.log.Error("failed to add key-value pair", "error", saveErr) h.log.Error("failed to add key-value pair", "error", saveErr)
} }
redirectToApp(writer, request, application.ID, "") http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
} }
// envPairJSON represents a key-value pair in the JSON request body. // HandleEnvVarAdd handles adding an environment variable.
type envPairJSON struct { func (h *Handlers) HandleEnvVarAdd() http.HandlerFunc {
Key string `json:"key"` return func(writer http.ResponseWriter, request *http.Request) {
Value string `json:"value"` h.addKeyValueToApp(
writer,
request,
func(ctx context.Context, application *models.App, key, value string) error {
envVar := models.NewEnvVar(h.db)
envVar.AppID = application.ID
envVar.Key = key
envVar.Value = value
return envVar.Save(ctx)
},
)
}
} }
// envVarMaxBodyBytes is the maximum allowed request body size for env var saves (1 MB). // HandleEnvVarDelete handles deleting an environment variable.
const envVarMaxBodyBytes = 1 << 20 func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc {
// validateEnvPairs validates env var pairs.
// It rejects empty keys and duplicate keys (returns a non-empty error string).
func validateEnvPairs(pairs []envPairJSON) ([]models.EnvVarPair, string) {
seen := make(map[string]bool, len(pairs))
result := make([]models.EnvVarPair, 0, len(pairs))
for _, p := range pairs {
trimmedKey := strings.TrimSpace(p.Key)
if trimmedKey == "" {
return nil, "empty environment variable key is not allowed"
}
if seen[trimmedKey] {
return nil, "duplicate environment variable key: " + trimmedKey
}
seen[trimmedKey] = true
result = append(result, models.EnvVarPair{Key: trimmedKey, Value: p.Value})
}
return result, ""
}
// HandleEnvVarSave handles bulk saving of all environment variables.
// It reads a JSON array of {key, value} objects from the request body,
// deletes all existing env vars for the app, and inserts the full
// submitted set atomically within a database transaction.
// Duplicate keys are rejected with a 400 Bad Request error.
func (h *Handlers) HandleEnvVarSave() 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, "envID")
application, findErr := models.FindApp(request.Context(), h.db, appID) envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
if findErr != nil || application == nil { if parseErr != nil {
http.NotFound(writer, request) http.NotFound(writer, request)
return return
} }
// Limit request body size to prevent abuse envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
request.Body = http.MaxBytesReader(writer, request.Body, envVarMaxBodyBytes) if findErr != nil || envVar == nil || envVar.AppID != appID {
http.NotFound(writer, request)
var pairs []envPairJSON
decodeErr := json.NewDecoder(request.Body).Decode(&pairs)
if decodeErr != nil {
h.respondJSON(writer, request, map[string]string{
jsonKeyError: "invalid request body",
}, http.StatusBadRequest)
return return
} }
modelPairs, validationErr := validateEnvPairs(pairs) deleteErr := envVar.Delete(request.Context())
if validationErr != "" { if deleteErr != nil {
h.respondJSON(writer, request, map[string]string{ h.log.Error("failed to delete env var", "error", deleteErr)
jsonKeyError: validationErr,
}, http.StatusBadRequest)
return
} }
replaceErr := models.ReplaceEnvVarsByAppID( http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
request.Context(), h.db, application.ID, modelPairs,
)
if replaceErr != nil {
h.log.Error("failed to replace env vars", "error", replaceErr)
h.respondJSON(writer, request, map[string]string{
jsonKeyError: "failed to save environment variables",
}, http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, map[string]bool{"ok": true}, http.StatusOK)
} }
} }
@@ -1020,77 +940,32 @@ func (h *Handlers) HandleLabelAdd() http.HandlerFunc {
} }
} }
// deleteAppResource handles deletion of an app-owned resource (label, // HandleLabelDelete handles deleting a label.
// volume, or port) identified by an int64 URL parameter. The func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
// deleteByID closure reports whether the resource was found to belong return func(writer http.ResponseWriter, request *http.Request) {
// to the app, and returns the deletion error if one occurred.
func (h *Handlers) deleteAppResource(
writer http.ResponseWriter,
request *http.Request,
idParam, logName string,
deleteByID deleteByIDFunc,
) {
appID := chi.URLParam(request, "id") appID := chi.URLParam(request, "id")
idStr := chi.URLParam(request, idParam) labelIDStr := chi.URLParam(request, "labelID")
id, parseErr := strconv.ParseInt(idStr, 10, 64) labelID, parseErr := strconv.ParseInt(labelIDStr, 10, 64)
if parseErr != nil { if parseErr != nil {
http.NotFound(writer, request) http.NotFound(writer, request)
return return
} }
found, deleteErr := deleteByID(request.Context(), appID, id) label, findErr := models.FindLabel(request.Context(), h.db, labelID)
if !found { if findErr != nil || label == nil || label.AppID != appID {
http.NotFound(writer, request) http.NotFound(writer, request)
return return
} }
deleteErr := label.Delete(request.Context())
if deleteErr != nil { if deleteErr != nil {
h.log.Error("failed to delete "+logName, "error", deleteErr) h.log.Error("failed to delete label", "error", deleteErr)
} }
redirectToApp(writer, request, appID, "") http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
// deleteByIDFunc looks up an app-owned resource by ID and deletes it
// when it belongs to the given app. It reports whether the resource
// was found, and returns lookup or deletion errors.
type deleteByIDFunc func(ctx context.Context, appID string, id int64) (bool, error)
// makeDeleteByID builds a deleteByIDFunc from a model's find
// function, its app-ID accessor, and its delete method.
func makeDeleteByID[T any](
db *database.Database,
find func(context.Context, *database.Database, int64) (*T, error),
appIDOf func(*T) string,
del func(*T, context.Context) error,
) deleteByIDFunc {
return func(ctx context.Context, appID string, id int64) (bool, error) {
resource, findErr := find(ctx, db, id)
if findErr != nil {
return false, findErr
}
if resource == nil || appIDOf(resource) != appID {
return false, nil
}
return true, del(resource, ctx)
}
}
// HandleLabelDelete handles deleting a label.
func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
h.deleteAppResource(
writer, request, "labelID", "label",
makeDeleteByID(h.db, models.FindLabel,
func(l *models.Label) string { return l.AppID },
(*models.Label).Delete,
),
)
} }
} }
@@ -1118,15 +993,12 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
readOnly := request.FormValue("readonly") == "1" readOnly := request.FormValue("readonly") == "1"
if hostPath == "" || containerPath == "" { if hostPath == "" || containerPath == "" {
redirectToApp(writer, request, application.ID, "") http.Redirect(
writer,
return request,
} "/apps/"+application.ID,
http.StatusSeeOther,
pathErr := validateVolumePaths(hostPath, containerPath) )
if pathErr != nil {
h.log.Error("invalid volume path", "error", pathErr)
redirectToApp(writer, request, application.ID, "")
return return
} }
@@ -1142,20 +1014,36 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
h.log.Error("failed to add volume", "error", saveErr) h.log.Error("failed to add volume", "error", saveErr)
} }
redirectToApp(writer, request, application.ID, "") http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
} }
} }
// HandleVolumeDelete handles deleting a volume mount. // HandleVolumeDelete handles deleting a volume mount.
func (h *Handlers) HandleVolumeDelete() http.HandlerFunc { func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
h.deleteAppResource( appID := chi.URLParam(request, "id")
writer, request, "volumeID", "volume", volumeIDStr := chi.URLParam(request, "volumeID")
makeDeleteByID(h.db, models.FindVolume,
func(v *models.Volume) string { return v.AppID }, volumeID, parseErr := strconv.ParseInt(volumeIDStr, 10, 64)
(*models.Volume).Delete, if parseErr != nil {
), http.NotFound(writer, request)
)
return
}
volume, findErr := models.FindVolume(request.Context(), h.db, volumeID)
if findErr != nil || volume == nil || volume.AppID != appID {
http.NotFound(writer, request)
return
}
deleteErr := volume.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete volume", "error", deleteErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
} }
} }
@@ -1183,7 +1071,7 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
request.FormValue("container_port"), request.FormValue("container_port"),
) )
if !valid { if !valid {
redirectToApp(writer, request, application.ID, "") http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return return
} }
@@ -1204,7 +1092,7 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
h.log.Error("failed to save port", "error", saveErr) h.log.Error("failed to save port", "error", saveErr)
} }
redirectToApp(writer, request, application.ID, "") http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
} }
} }
@@ -1228,13 +1116,29 @@ func parsePortValues(hostPortStr, containerPortStr string) (int, int, bool) {
// HandlePortDelete handles deleting a port mapping. // HandlePortDelete handles deleting a port mapping.
func (h *Handlers) HandlePortDelete() http.HandlerFunc { func (h *Handlers) HandlePortDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
h.deleteAppResource( appID := chi.URLParam(request, "id")
writer, request, "portID", "port", portIDStr := chi.URLParam(request, "portID")
makeDeleteByID(h.db, models.FindPort,
func(p *models.Port) string { return p.AppID }, portID, parseErr := strconv.ParseInt(portIDStr, 10, 64)
(*models.Port).Delete, if parseErr != nil {
), http.NotFound(writer, request)
)
return
}
port, findErr := models.FindPort(request.Context(), h.db, portID)
if findErr != nil || port == nil || port.AppID != appID {
http.NotFound(writer, request)
return
}
deleteErr := port.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete port", "error", deleteErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
} }
} }
@@ -1265,6 +1169,59 @@ func ValidateVolumePath(p string) error {
return nil return nil
} }
// HandleEnvVarEdit handles editing an existing environment variable.
func (h *Handlers) HandleEnvVarEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
envVarIDStr := chi.URLParam(request, "varID")
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
if findErr != nil || envVar == nil || envVar.AppID != appID {
http.NotFound(writer, request)
return
}
formErr := request.ParseForm()
if formErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
key := request.FormValue("key")
value := request.FormValue("value")
if key == "" || value == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
envVar.Key = key
envVar.Value = value
saveErr := envVar.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update env var", "error", saveErr)
}
http.Redirect(
writer,
request,
"/apps/"+appID+"?success=env-updated",
http.StatusSeeOther,
)
}
}
// HandleLabelEdit handles editing an existing label. // HandleLabelEdit handles editing an existing label.
func (h *Handlers) HandleLabelEdit() http.HandlerFunc { func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@@ -1296,7 +1253,7 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
value := request.FormValue("value") value := request.FormValue("value")
if key == "" || value == "" { if key == "" || value == "" {
redirectToApp(writer, request, appID, "") http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return return
} }
@@ -1309,7 +1266,7 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
h.log.Error("failed to update label", "error", saveErr) h.log.Error("failed to update label", "error", saveErr)
} }
redirectToApp(writer, request, appID, "") http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
} }
} }
@@ -1345,7 +1302,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
readOnly := request.FormValue("readonly") == "1" readOnly := request.FormValue("readonly") == "1"
if hostPath == "" || containerPath == "" { if hostPath == "" || containerPath == "" {
redirectToApp(writer, request, appID, "") http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return return
} }
@@ -1353,7 +1310,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
pathErr := validateVolumePaths(hostPath, containerPath) pathErr := validateVolumePaths(hostPath, containerPath)
if pathErr != nil { if pathErr != nil {
h.log.Error("invalid volume path", "error", pathErr) h.log.Error("invalid volume path", "error", pathErr)
redirectToApp(writer, request, appID, "") http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return return
} }
@@ -1367,7 +1324,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
h.log.Error("failed to update volume", "error", saveErr) h.log.Error("failed to update volume", "error", saveErr)
} }
redirectToApp(writer, request, appID, "") http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
} }
} }
@@ -1386,132 +1343,6 @@ func validateVolumePaths(hostPath, containerPath string) error {
return nil return nil
} }
// ErrInvalidMemoryFormat is returned when a memory limit string cannot be parsed.
var ErrInvalidMemoryFormat = errors.New(
"must be a number with optional unit suffix (e.g. 256m, 1g, 512000000)",
)
// ErrNegativeValue is returned when a resource limit is negative.
var ErrNegativeValue = errors.New("value must be positive")
// Memory unit byte multipliers.
const (
kilobyte = 1024
megabyte = 1024 * 1024
gigabyte = 1024 * 1024 * 1024
)
// optionalNullString converts a form value to a sql.NullString.
// Returns a valid NullString if non-empty, invalid (NULL) if empty.
func optionalNullString(s string) sql.NullString {
if s != "" {
return sql.NullString{String: s, Valid: true}
}
return sql.NullString{}
}
// applyResourceLimits parses CPU and memory limit form values and
// applies them to the app. Returns an error message string if
// validation fails, or empty string on success.
func applyResourceLimits(application *models.App, request *http.Request) string {
cpuLimit, cpuErr := parseOptionalFloat64(request.FormValue("cpu_limit"))
if cpuErr != nil {
return "Invalid CPU limit: must be a positive number (e.g. 0.5, 1, 2)"
}
application.CPULimit = cpuLimit
memoryLimit, memErr := parseOptionalMemoryBytes(request.FormValue("memory_limit"))
if memErr != nil {
return "Invalid memory limit: " + memErr.Error()
}
application.MemoryLimit = memoryLimit
return ""
}
// memoryUnitMultiplier returns the byte multiplier for a memory unit suffix.
// Returns 0 if the suffix is not recognized.
func memoryUnitMultiplier(suffix byte) int64 {
switch suffix {
case 'k':
return kilobyte
case 'm':
return megabyte
case 'g':
return gigabyte
default:
return 0
}
}
// parseOptionalFloat64 parses an optional float64 form field.
// Returns a valid NullFloat64 if the string is non-empty and parses
// to a positive number.
// Returns an empty NullFloat64 if the string is empty.
// Returns an error if the string is non-empty but invalid or non-positive.
func parseOptionalFloat64(s string) (sql.NullFloat64, error) {
s = strings.TrimSpace(s)
if s == "" {
return sql.NullFloat64{}, nil
}
val, err := strconv.ParseFloat(s, 64)
if err != nil {
return sql.NullFloat64{}, fmt.Errorf("invalid number: %w", err)
}
if val <= 0 {
return sql.NullFloat64{}, ErrNegativeValue
}
return sql.NullFloat64{Float64: val, Valid: true}, nil
}
// parseOptionalMemoryBytes parses an optional memory limit string into bytes.
// Accepts plain bytes (e.g. "536870912") or suffixed values
// (e.g. "512m", "1g", "256k").
// Returns a valid NullInt64 with bytes if non-empty, empty NullInt64 if blank.
func parseOptionalMemoryBytes(s string) (sql.NullInt64, error) {
s = strings.TrimSpace(s)
if s == "" {
return sql.NullInt64{}, nil
}
s = strings.ToLower(s)
// Check for unit suffix
multiplier := memoryUnitMultiplier(s[len(s)-1])
if multiplier > 0 {
numStr := s[:len(s)-1]
val, err := strconv.ParseFloat(numStr, 64)
if err != nil {
return sql.NullInt64{}, ErrInvalidMemoryFormat
}
if val <= 0 {
return sql.NullInt64{}, ErrNegativeValue
}
return sql.NullInt64{Int64: int64(val * float64(multiplier)), Valid: true}, nil
}
// Plain bytes
val, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return sql.NullInt64{}, ErrInvalidMemoryFormat
}
if val <= 0 {
return sql.NullInt64{}, ErrNegativeValue
}
return sql.NullInt64{Int64: val, Valid: true}, nil
}
// formatDeployKey formats an SSH public key with a descriptive comment. // formatDeployKey formats an SSH public key with a descriptive comment.
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp // Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string { func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {

View File

@@ -21,16 +21,8 @@ func TestValidateAppName(t *testing.T) {
{"empty", "", true}, {"empty", "", true},
{"single char", "a", true}, {"single char", "a", true},
{"too long", "a" + string(make([]byte, 63)), true}, {"too long", "a" + string(make([]byte, 63)), true},
{ {"exactly 63 chars", "a23456789012345678901234567890123456789012345678901234567890123", false},
"exactly 63 chars", {"64 chars", "a234567890123456789012345678901234567890123456789012345678901234", true},
"a23456789012345678901234567890123456789012345678901234567890123",
false,
},
{
"64 chars",
"a234567890123456789012345678901234567890123456789012345678901234",
true,
},
{"uppercase", "MyApp", true}, {"uppercase", "MyApp", true},
{"spaces", "my app", true}, {"spaces", "my app", true},
{"starts with hyphen", "-myapp", true}, {"starts with hyphen", "-myapp", true},

View File

@@ -3,7 +3,7 @@ package handlers
import ( import (
"net/http" "net/http"
"sneak.berlin/go/upaas/templates" "git.eeqj.de/sneak/upaas/templates"
) )
// HandleLoginGET returns the login page handler. // HandleLoginGET returns the login page handler.

View File

@@ -4,8 +4,8 @@ import (
"net/http" "net/http"
"time" "time"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/templates" "git.eeqj.de/sneak/upaas/templates"
) )
// AppStats holds deployment statistics for an app. // AppStats holds deployment statistics for an app.

View File

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

View File

@@ -10,29 +10,16 @@ import (
"github.com/gorilla/csrf" "github.com/gorilla/csrf"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker" "git.eeqj.de/sneak/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/healthcheck" "git.eeqj.de/sneak/upaas/internal/healthcheck"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/webhook" "git.eeqj.de/sneak/upaas/internal/service/webhook"
"sneak.berlin/go/upaas/templates" "git.eeqj.de/sneak/upaas/templates"
)
// Template data keys shared across handlers.
const (
dataKeyApp = "App"
dataKeyError = "Error"
)
// JSON response keys shared across handlers.
const (
jsonKeyError = "error"
jsonKeyLogs = "logs"
jsonKeyStatus = "status"
) )
// Params contains dependencies for Handlers. // Params contains dependencies for Handlers.

View File

@@ -15,26 +15,21 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker" "git.eeqj.de/sneak/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/handlers" "git.eeqj.de/sneak/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/healthcheck" "git.eeqj.de/sneak/upaas/internal/healthcheck"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/middleware" "git.eeqj.de/sneak/upaas/internal/middleware"
"sneak.berlin/go/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/notify" "git.eeqj.de/sneak/upaas/internal/service/notify"
"sneak.berlin/go/upaas/internal/service/webhook" "git.eeqj.de/sneak/upaas/internal/service/webhook"
)
const (
branchMain = "main"
paramSecret = "secret"
) )
type testContext struct { type testContext struct {
@@ -198,8 +193,7 @@ func TestHandleHealthCheck(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext( request := httptest.NewRequest(
t.Context(),
http.MethodGet, http.MethodGet,
"/.well-known/healthcheck.json", "/.well-known/healthcheck.json",
nil, nil,
@@ -216,26 +210,6 @@ func TestHandleHealthCheck(t *testing.T) {
}) })
} }
// assertPageRenders serves a GET request for path with the given
// handler and asserts a 200 response containing want.
func assertPageRenders(
t *testing.T,
handler http.Handler,
path, want string,
) {
t.Helper()
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil,
)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), want)
}
func TestHandleSetupGET(t *testing.T) { func TestHandleSetupGET(t *testing.T) {
t.Parallel() t.Parallel()
@@ -243,7 +217,15 @@ func TestHandleSetupGET(t *testing.T) {
t.Parallel() t.Parallel()
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
assertPageRenders(t, testCtx.handlers.HandleSetupGET(), "/setup", "setup")
request := httptest.NewRequest(http.MethodGet, "/setup", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupGET()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "setup")
}) })
} }
@@ -255,8 +237,7 @@ func createSetupFormRequest(
form.Set("password", password) form.Set("password", password)
form.Set("password_confirm", confirm) form.Set("password_confirm", confirm)
request := httptest.NewRequestWithContext( request := httptest.NewRequest(
context.Background(),
http.MethodPost, http.MethodPost,
"/setup", "/setup",
strings.NewReader(form.Encode()), strings.NewReader(form.Encode()),
@@ -333,7 +314,15 @@ func TestHandleLoginGET(t *testing.T) {
t.Parallel() t.Parallel()
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
assertPageRenders(t, testCtx.handlers.HandleLoginGET(), "/login", "login")
request := httptest.NewRequest(http.MethodGet, "/login", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginGET()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "login")
}) })
} }
@@ -342,8 +331,7 @@ func createLoginFormRequest(username, password string) *http.Request {
form.Set("username", username) form.Set("username", username)
form.Set("password", password) form.Set("password", password)
request := httptest.NewRequestWithContext( request := httptest.NewRequest(
context.Background(),
http.MethodPost, http.MethodPost,
"/login", "/login",
strings.NewReader(form.Encode()), strings.NewReader(form.Encode()),
@@ -407,9 +395,7 @@ func TestHandleDashboard(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext( request := httptest.NewRequest(http.MethodGet, "/", nil)
t.Context(), http.MethodGet, "/", nil,
)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard() handler := testCtx.handlers.HandleDashboard()
@@ -418,27 +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.NewRequestWithContext(
t.Context(), 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) {
@@ -449,9 +414,7 @@ func TestHandleAppNew(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext( request := httptest.NewRequest(http.MethodGet, "/apps/new", nil)
t.Context(), http.MethodGet, "/apps/new", nil,
)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleAppNew() handler := testCtx.handlers.HandleAppNew()
@@ -490,7 +453,7 @@ func createTestApp(
app.CreateAppInput{ app.CreateAppInput{
Name: name, Name: name,
RepoURL: "git@example.com:user/" + name + ".git", RepoURL: "git@example.com:user/" + name + ".git",
Branch: branchMain, Branch: "main",
}, },
) )
require.NoError(t, err) require.NoError(t, err)
@@ -511,7 +474,7 @@ func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
app.CreateAppInput{ app.CreateAppInput{
Name: "oversize-test-app", Name: "oversize-test-app",
RepoURL: "git@example.com:user/repo.git", RepoURL: "git@example.com:user/repo.git",
Branch: branchMain, Branch: "main",
}, },
) )
require.NoError(t, createErr) require.NoError(t, createErr)
@@ -519,15 +482,14 @@ func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
// Create a body larger than 1MB - it should be silently truncated // Create a body larger than 1MB - it should be silently truncated
// and the webhook should still process (or fail gracefully on parse) // and the webhook should still process (or fail gracefully on parse)
largePayload := strings.Repeat("x", 2*1024*1024) // 2MB largePayload := strings.Repeat("x", 2*1024*1024) // 2MB
request := httptest.NewRequestWithContext( request := httptest.NewRequest(
t.Context(),
http.MethodPost, http.MethodPost,
"/webhook/"+createdApp.WebhookSecret, "/webhook/"+createdApp.WebhookSecret,
strings.NewReader(largePayload), strings.NewReader(largePayload),
) )
request = addChiURLParams( request = addChiURLParams(
request, request,
map[string]string{paramSecret: createdApp.WebhookSecret}, map[string]string{"secret": createdApp.WebhookSecret},
) )
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Gitea-Event", "push") request.Header.Set("X-Gitea-Event", "push")
@@ -563,8 +525,7 @@ func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) {
resourceID := cfg.createFn(t, testCtx, app1) resourceID := cfg.createFn(t, testCtx, app1)
request := httptest.NewRequestWithContext( request := httptest.NewRequest(
t.Context(),
http.MethodPost, http.MethodPost,
cfg.deletePath(app2.ID, resourceID), cfg.deletePath(app2.ID, resourceID),
nil, nil,
@@ -580,249 +541,45 @@ func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) {
cfg.verifyFn(t, testCtx, resourceID) cfg.verifyFn(t, testCtx, resourceID)
} }
// TestHandleEnvVarSaveBulk tests that HandleEnvVarSave replaces all env vars // TestDeleteEnvVarOwnershipVerification tests that deleting an env var
// for an app with the submitted set (monolithic delete-all + insert-all). // via another app's URL path returns 404 (IDOR prevention).
func TestHandleEnvVarSaveBulk(t *testing.T) { func TestDeleteEnvVarOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern
t.Parallel() t.Parallel()
testCtx := setupTestHandlers(t) testOwnershipVerification(t, ownedResourceTestConfig{
createdApp := createTestApp(t, testCtx, "envvar-bulk-app") appPrefix1: "envvar-owner-app",
appPrefix2: "envvar-other-app",
createFn: func(t *testing.T, tc *testContext, ownerApp *models.App) int64 {
t.Helper()
// Create some pre-existing env vars envVar := models.NewEnvVar(tc.database)
for _, kv := range [][2]string{{"OLD_KEY", "old_value"}, {"REMOVE_ME", "gone"}} { envVar.AppID = ownerApp.ID
ev := models.NewEnvVar(testCtx.database) envVar.Key = "SECRET"
ev.AppID = createdApp.ID envVar.Value = "hunter2"
ev.Key = kv[0] require.NoError(t, envVar.Save(context.Background()))
ev.Value = kv[1]
require.NoError(t, ev.Save(context.Background()))
}
// Submit a new set as a JSON array of key/value objects return envVar.ID
body := `[{"key":"NEW_KEY","value":"new_value"},{"key":"ANOTHER","value":"42"}]` },
deletePath: func(appID string, resourceID int64) string {
return "/apps/" + appID + "/env/" + strconv.FormatInt(resourceID, 10) + "/delete"
},
chiParams: func(appID string, resourceID int64) map[string]string {
return map[string]string{"id": appID, "envID": strconv.FormatInt(resourceID, 10)}
},
handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleEnvVarDelete() },
verifyFn: func(t *testing.T, tc *testContext, resourceID int64) {
t.Helper()
r := chi.NewRouter() found, findErr := models.FindEnvVar(context.Background(), tc.database, resourceID)
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) require.NoError(t, findErr)
assert.NotNil(t, found, "env var should still exist after IDOR attempt")
request := httptest.NewRequestWithContext( },
t.Context(), })
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
// Verify old env vars are gone and new ones exist
envVars, err := models.FindEnvVarsByAppID(
context.Background(), testCtx.database, createdApp.ID,
)
require.NoError(t, err)
assert.Len(t, envVars, 2)
keys := make(map[string]string)
for _, ev := range envVars {
keys[ev.Key] = ev.Value
}
assert.Equal(t, "new_value", keys["NEW_KEY"])
assert.Equal(t, "42", keys["ANOTHER"])
assert.Empty(t, keys["OLD_KEY"], "old env vars should be deleted")
assert.Empty(t, keys["REMOVE_ME"], "old env vars should be deleted")
}
// TestHandleEnvVarSaveAppNotFound tests that HandleEnvVarSave returns 404
// for a non-existent app.
func TestHandleEnvVarSaveAppNotFound(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
body := `[{"key":"KEY","value":"value"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost,
"/apps/nonexistent-id/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusNotFound, recorder.Code)
}
// TestHandleEnvVarSaveEmptyKeyRejected verifies that submitting a JSON
// array containing an entry with an empty key returns 400.
func TestHandleEnvVarSaveEmptyKeyRejected(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-emptykey-app")
body := `[{"key":"VALID_KEY","value":"ok"},{"key":"","value":"bad"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
}
// TestHandleEnvVarSaveDuplicateKeyRejected verifies that when the client
// sends duplicate keys, the server rejects them with 400 Bad Request.
func TestHandleEnvVarSaveDuplicateKeyRejected(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-dedup-app")
// Send two entries with the same key — should be rejected
body := `[{"key":"FOO","value":"first"},{"key":"BAR","value":"bar"},` +
`{"key":"FOO","value":"second"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.Contains(t, recorder.Body.String(), "duplicate environment variable key: FOO")
}
// TestHandleEnvVarSaveCrossAppIsolation verifies that posting env vars
// to appA's endpoint does not affect appB's env vars (IDOR prevention).
func TestHandleEnvVarSaveCrossAppIsolation(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
appA := createTestApp(t, testCtx, "envvar-iso-appA")
appB := createTestApp(t, testCtx, "envvar-iso-appB")
// Give appB some env vars
for _, kv := range [][2]string{{"B_KEY1", "b_val1"}, {"B_KEY2", "b_val2"}} {
ev := models.NewEnvVar(testCtx.database)
ev.AppID = appB.ID
ev.Key = kv[0]
ev.Value = kv[1]
require.NoError(t, ev.Save(context.Background()))
}
// POST new env vars to appA's endpoint
body := `[{"key":"A_KEY","value":"a_val"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost,
"/apps/"+appA.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
// Verify appA has exactly what we sent
appAVars, err := models.FindEnvVarsByAppID(
context.Background(), testCtx.database, appA.ID,
)
require.NoError(t, err)
assert.Len(t, appAVars, 1)
assert.Equal(t, "A_KEY", appAVars[0].Key)
// Verify appB's env vars are completely untouched
appBVars, err := models.FindEnvVarsByAppID(
context.Background(), testCtx.database, appB.ID,
)
require.NoError(t, err)
assert.Len(t, appBVars, 2, "appB env vars must not be affected")
bKeys := make(map[string]string)
for _, ev := range appBVars {
bKeys[ev.Key] = ev.Value
}
assert.Equal(t, "b_val1", bKeys["B_KEY1"])
assert.Equal(t, "b_val2", bKeys["B_KEY2"])
}
// TestHandleEnvVarSaveBodySizeLimit verifies that a request body
// exceeding the 1 MB limit is rejected.
func TestHandleEnvVarSaveBodySizeLimit(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-sizelimit-app")
// Build a JSON body that exceeds 1 MB
// Each entry is ~30 bytes; 40000 entries ≈ 1.2 MB
var sb strings.Builder
sb.WriteString("[")
for i := range 40000 {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(`{"key":"K` + strconv.Itoa(i) + `","value":"val"}`)
}
sb.WriteString("]")
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(sb.String()),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusBadRequest, recorder.Code,
"oversized body should be rejected with 400")
} }
// TestDeleteLabelOwnershipVerification tests that deleting a label // TestDeleteLabelOwnershipVerification tests that deleting a label
// via another app's URL path returns 404 (IDOR prevention). // via another app's URL path returns 404 (IDOR prevention).
func TestDeleteLabelOwnershipVerification(t *testing.T) { func TestDeleteLabelOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern
t.Parallel() t.Parallel()
testOwnershipVerification(t, ownedResourceTestConfig{ testOwnershipVerification(t, ownedResourceTestConfig{
@@ -875,8 +632,7 @@ func TestDeleteVolumeOwnershipVerification(t *testing.T) {
require.NoError(t, volume.Save(context.Background())) require.NoError(t, volume.Save(context.Background()))
// Try to delete app1's volume using app2's URL path // Try to delete app1's volume using app2's URL path
request := httptest.NewRequestWithContext( request := httptest.NewRequest(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/"+app2.ID+"/volumes/"+strconv.FormatInt(volume.ID, 10)+"/delete", "/apps/"+app2.ID+"/volumes/"+strconv.FormatInt(volume.ID, 10)+"/delete",
nil, nil,
@@ -917,8 +673,7 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
require.NoError(t, port.Save(context.Background())) require.NoError(t, port.Save(context.Background()))
// Try to delete app1's port using app2's URL path // Try to delete app1's port using app2's URL path
request := httptest.NewRequestWithContext( request := httptest.NewRequest(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/"+app2.ID+"/ports/"+strconv.FormatInt(port.ID, 10)+"/delete", "/apps/"+app2.ID+"/ports/"+strconv.FormatInt(port.ID, 10)+"/delete",
nil, nil,
@@ -940,168 +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")
} }
// TestHandleEnvVarSaveEmptyClears verifies that submitting an empty JSON
// array deletes all existing env vars for the app.
func TestHandleEnvVarSaveEmptyClears(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-clear-app")
// Create a pre-existing env var
ev := models.NewEnvVar(testCtx.database)
ev.AppID = createdApp.ID
ev.Key = "DELETE_ME"
ev.Value = "gone"
require.NoError(t, ev.Save(context.Background()))
// Submit empty JSON array
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader("[]"),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
// Verify all env vars are gone
envVars, err := models.FindEnvVarsByAppID(
context.Background(), testCtx.database, createdApp.ID,
)
require.NoError(t, err)
assert.Empty(t, envVars, "all env vars should be deleted")
}
// 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.NewRequestWithContext(
t.Context(),
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.NewRequestWithContext(
t.Context(), 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.NewRequestWithContext(
t.Context(), 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()
@@ -1109,8 +702,7 @@ func TestHandleCancelDeployRedirects(t *testing.T) {
createdApp := createTestApp(t, testCtx, "cancel-deploy-app") createdApp := createTestApp(t, testCtx, "cancel-deploy-app")
request := httptest.NewRequestWithContext( request := httptest.NewRequest(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/"+createdApp.ID+"/deployments/cancel", "/apps/"+createdApp.ID+"/deployments/cancel",
nil, nil,
@@ -1130,8 +722,7 @@ func TestHandleCancelDeployReturns404ForUnknownApp(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext( request := httptest.NewRequest(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/nonexistent/deployments/cancel", "/apps/nonexistent/deployments/cancel",
nil, nil,
@@ -1152,16 +743,12 @@ func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
webhookURL := "/webhook/unknown-secret" webhookURL := "/webhook/unknown-secret"
payload := `{"ref": "refs/heads/main"}` payload := `{"ref": "refs/heads/main"}`
request := httptest.NewRequestWithContext( request := httptest.NewRequest(
t.Context(),
http.MethodPost, http.MethodPost,
webhookURL, webhookURL,
strings.NewReader(payload), strings.NewReader(payload),
) )
request = addChiURLParams( request = addChiURLParams(request, map[string]string{"secret": "unknown-secret"})
request,
map[string]string{paramSecret: "unknown-secret"},
)
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Gitea-Event", "push") request.Header.Set("X-Gitea-Event", "push")
@@ -1184,22 +771,21 @@ func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
app.CreateAppInput{ app.CreateAppInput{
Name: "webhook-test-app", Name: "webhook-test-app",
RepoURL: "git@example.com:user/repo.git", RepoURL: "git@example.com:user/repo.git",
Branch: branchMain, Branch: "main",
}, },
) )
require.NoError(t, createErr) require.NoError(t, createErr)
payload := `{"ref": "refs/heads/main", "after": "abc123"}` payload := `{"ref": "refs/heads/main", "after": "abc123"}`
webhookURL := "/webhook/" + createdApp.WebhookSecret webhookURL := "/webhook/" + createdApp.WebhookSecret
request := httptest.NewRequestWithContext( request := httptest.NewRequest(
t.Context(),
http.MethodPost, http.MethodPost,
webhookURL, webhookURL,
strings.NewReader(payload), strings.NewReader(payload),
) )
request = addChiURLParams( request = addChiURLParams(
request, request,
map[string]string{paramSecret: createdApp.WebhookSecret}, map[string]string{"secret": createdApp.WebhookSecret},
) )
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Gitea-Event", "push") request.Header.Set("X-Gitea-Event", "push")

View File

@@ -16,9 +16,7 @@ func TestRenderTemplateBuffersOutput(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
// The setup page is simple and has no DB dependencies // The setup page is simple and has no DB dependencies
request := httptest.NewRequestWithContext( request := httptest.NewRequest(http.MethodGet, "/setup", nil)
t.Context(), http.MethodGet, "/setup", nil,
)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupGET() handler := testCtx.handlers.HandleSetupGET()
@@ -41,7 +39,7 @@ func TestDashboardRenderTemplateBuffersOutput(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) request := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard() handler := testCtx.handlers.HandleDashboard()
@@ -61,9 +59,7 @@ func TestLoginRenderTemplateBuffersOutput(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext( request := httptest.NewRequest(http.MethodGet, "/login", nil)
t.Context(), http.MethodGet, "/login", nil,
)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginGET() handler := testCtx.handlers.HandleLoginGET()

View File

@@ -1,82 +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,76 +0,0 @@
package handlers_test
import (
"testing"
"sneak.berlin/go/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,195 +0,0 @@
package handlers //nolint:testpackage // tests unexported parsing functions
import (
"database/sql"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseOptionalFloat64(t *testing.T) {
t.Parallel()
t.Run("empty string returns invalid", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalFloat64("")
require.NoError(t, err)
assert.False(t, result.Valid)
})
t.Run("whitespace only returns invalid", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalFloat64(" ")
require.NoError(t, err)
assert.False(t, result.Valid)
})
t.Run("valid float", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalFloat64("0.5")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.InDelta(t, 0.5, result.Float64, 0.001)
})
t.Run("valid integer", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalFloat64("2")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.InDelta(t, 2.0, result.Float64, 0.001)
})
t.Run("negative value rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalFloat64("-1")
require.Error(t, err)
})
t.Run("zero value rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalFloat64("0")
require.Error(t, err)
})
t.Run("non-numeric rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalFloat64("abc")
require.Error(t, err)
})
}
func TestParseOptionalMemoryBytes(t *testing.T) { //nolint:funlen // table-driven test
t.Parallel()
t.Run("empty string returns invalid", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("")
require.NoError(t, err)
assert.False(t, result.Valid)
})
t.Run("whitespace only returns invalid", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes(" ")
require.NoError(t, err)
assert.False(t, result.Valid)
})
t.Run("plain bytes", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("536870912")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(536870912), result.Int64)
})
t.Run("megabytes suffix", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("256m")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(256*1024*1024), result.Int64)
})
t.Run("megabytes suffix uppercase", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("256M")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(256*1024*1024), result.Int64)
})
t.Run("gigabytes suffix", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("1g")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(1024*1024*1024), result.Int64)
})
t.Run("kilobytes suffix", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("512k")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(512*1024), result.Int64)
})
t.Run("fractional gigabytes", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("1.5g")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(1.5*1024*1024*1024), result.Int64)
})
t.Run("negative value rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalMemoryBytes("-256m")
require.Error(t, err)
})
t.Run("zero value rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalMemoryBytes("0")
require.Error(t, err)
})
t.Run("invalid string rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalMemoryBytes("abc")
require.Error(t, err)
})
t.Run("negative plain bytes rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalMemoryBytes("-100")
require.Error(t, err)
})
}
func TestAppResourceLimitsRoundTrip(t *testing.T) {
t.Parallel()
// Test that parsing and formatting are consistent
tests := []struct {
input string
expected sql.NullInt64
format string
}{
{"256m", sql.NullInt64{Int64: 256 * 1024 * 1024, Valid: true}, "256m"},
{"1g", sql.NullInt64{Int64: 1024 * 1024 * 1024, Valid: true}, "1g"},
{"512k", sql.NullInt64{Int64: 512 * 1024, Valid: true}, "512k"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes(tt.input)
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -1,33 +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"
"sneak.berlin/go/upaas/internal/handlers"
)
func TestSanitizeLogs(t *testing.T) {
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

@@ -3,7 +3,7 @@ package handlers
import ( import (
"net/http" "net/http"
"sneak.berlin/go/upaas/templates" "git.eeqj.de/sneak/upaas/templates"
) )
const ( const (
@@ -56,7 +56,7 @@ func (h *Handlers) renderSetupError(
) { ) {
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"Username": username, "Username": username,
dataKeyError: errorMsg, "Error": errorMsg,
}, request) }, request)
h.renderTemplate(writer, tmpl, "setup.html", data) h.renderTemplate(writer, tmpl, "setup.html", data)
} }

View File

@@ -3,7 +3,7 @@ package handlers_test
import ( import (
"testing" "testing"
"sneak.berlin/go/upaas/internal/handlers" "git.eeqj.de/sneak/upaas/internal/handlers"
) )
func TestSanitizeTail(t *testing.T) { func TestSanitizeTail(t *testing.T) {

View File

@@ -6,15 +6,13 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/webhook"
) )
// maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB). // maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB).
const maxWebhookBodySize = 1 << 20 const maxWebhookBodySize = 1 << 20
// HandleWebhook handles incoming webhooks from Gitea, GitHub, or GitLab. // HandleWebhook handles incoming Gitea webhooks.
// The webhook source is auto-detected from HTTP headers.
func (h *Handlers) HandleWebhook() http.HandlerFunc { func (h *Handlers) HandleWebhook() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
secret := chi.URLParam(request, "secret") secret := chi.URLParam(request, "secret")
@@ -52,17 +50,16 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
return return
} }
// Auto-detect webhook source from headers // Get event type from header
source := webhook.DetectWebhookSource(request.Header) eventType := request.Header.Get("X-Gitea-Event")
if eventType == "" {
// Extract event type based on detected source eventType = "push"
eventType := webhook.DetectEventType(request.Header, source) }
// Process webhook // Process webhook
webhookErr := h.webhook.HandleWebhook( webhookErr := h.webhook.HandleWebhook(
request.Context(), request.Context(),
application, application,
source,
eventType, eventType,
body, body,
) )

View File

@@ -1,56 +0,0 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/templates"
)
// webhookEventsLimit is the number of webhook events to show in history.
const webhookEventsLimit = 100
// HandleAppWebhookEvents returns the webhook event history handler.
func (h *Handlers) HandleAppWebhookEvents() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil {
h.log.Error("failed to find app", "error", findErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
if application == nil {
http.NotFound(writer, request)
return
}
events, eventsErr := application.GetWebhookEvents(
request.Context(),
webhookEventsLimit,
)
if eventsErr != nil {
h.log.Error("failed to get webhook events",
"error", eventsErr,
"app", appID,
)
events = []*models.WebhookEvent{}
}
data := h.addGlobals(map[string]any{
dataKeyApp: application,
"Events": events,
}, request)
h.renderTemplate(writer, tmpl, "webhook_events.html", data)
}
}

View File

@@ -8,10 +8,10 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
) )
// Params contains dependencies for Healthcheck. // Params contains dependencies for Healthcheck.

View File

@@ -7,7 +7,7 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
) )
// Params contains dependencies for Logger. // Params contains dependencies for Logger.

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,79 +0,0 @@
package middleware //nolint:testpackage // tests internal CORS behavior
import (
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/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",
},
},
}
}
// assertNoCORSHeaders runs a request with the given Origin header through
// CORS middleware configured with corsOrigins and asserts that no
// Access-Control-Allow-Origin header is set.
func assertNoCORSHeaders(t *testing.T, corsOrigins, origin, msg string) {
t.Helper()
m := newCORSTestMiddleware(corsOrigins)
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.Header.Set("Origin", origin)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"), msg)
}
func TestCORS_NoOriginsConfigured_NoCORSHeaders(t *testing.T) {
t.Parallel()
assertNoCORSHeaders(t, "", "https://evil.com",
"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.NewRequestWithContext(t.Context(), 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()
assertNoCORSHeaders(t, "https://app.example.com", "https://evil.com",
"expected no CORS headers for non-matching origin")
}

View File

@@ -18,10 +18,10 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/auth"
) )
// corsMaxAge is the maximum age for CORS preflight responses in seconds. // corsMaxAge is the maximum age for CORS preflight responses in seconds.
@@ -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 == "" {
@@ -370,9 +339,8 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
} }
} }
// APISessionAuth returns middleware that requires session authentication // APISessionAuth returns middleware that requires session authentication for API routes.
// for API routes. Unlike SessionAuth, it returns JSON 401 responses instead // Unlike SessionAuth, it returns JSON 401 responses instead of redirecting to /login.
// of redirecting to /login.
func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler { func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func( return http.HandlerFunc(func(
@@ -412,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

@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
) )
func newTestMiddleware(t *testing.T) *Middleware { func newTestMiddleware(t *testing.T) *Middleware {
@@ -30,15 +30,13 @@ func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
mw := newTestMiddleware(t) mw := newTestMiddleware(t)
handler := mw.LoginRateLimit()(http.HandlerFunc( handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}, }))
))
// First 5 requests should succeed (burst) // First 5 requests should succeed (burst)
for i := range 5 { for i := range 5 {
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil) req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.168.1.1:12345" req.RemoteAddr = "192.168.1.1:12345"
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
@@ -46,12 +44,11 @@ func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
} }
// 6th request should be rate limited // 6th request should be rate limited
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil) req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.168.1.1:12345" req.RemoteAddr = "192.168.1.1:12345"
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusTooManyRequests, rec.Code, assert.Equal(t, http.StatusTooManyRequests, rec.Code, "6th request should be rate limited")
"6th request should be rate limited")
} }
//nolint:paralleltest // mutates global loginLimiter //nolint:paralleltest // mutates global loginLimiter
@@ -60,29 +57,27 @@ func TestLoginRateLimitIsolatesIPs(t *testing.T) {
mw := newTestMiddleware(t) mw := newTestMiddleware(t)
handler := mw.LoginRateLimit()(http.HandlerFunc( handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}, }))
))
// Exhaust IP1's budget // Exhaust IP1's budget
for range 5 { for range 5 {
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil) req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = testProxyAddr req.RemoteAddr = "10.0.0.1:1234"
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
} }
// IP1 should be blocked // IP1 should be blocked
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil) req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = testProxyAddr req.RemoteAddr = "10.0.0.1:1234"
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusTooManyRequests, rec.Code) assert.Equal(t, http.StatusTooManyRequests, rec.Code)
// IP2 should still work // IP2 should still work
req2 := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil) req2 := httptest.NewRequest(http.MethodPost, "/login", nil)
req2.RemoteAddr = "10.0.0.2:1234" req2.RemoteAddr = "10.0.0.2:1234"
rec2 := httptest.NewRecorder() rec2 := httptest.NewRecorder()
handler.ServeHTTP(rec2, req2) handler.ServeHTTP(rec2, req2)
@@ -95,28 +90,25 @@ func TestLoginRateLimitReturns429Body(t *testing.T) {
mw := newTestMiddleware(t) mw := newTestMiddleware(t)
handler := mw.LoginRateLimit()(http.HandlerFunc( handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}, }))
))
// Exhaust burst // Exhaust burst
for range 5 { for range 5 {
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil) req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "172.16.0.1:5555" req.RemoteAddr = "172.16.0.1:5555"
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
} }
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil) req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "172.16.0.1:5555" req.RemoteAddr = "172.16.0.1:5555"
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusTooManyRequests, rec.Code) assert.Equal(t, http.StatusTooManyRequests, rec.Code)
assert.Contains(t, rec.Body.String(), "Too Many Requests") assert.Contains(t, rec.Body.String(), "Too Many Requests")
assert.NotEmpty(t, rec.Header().Get("Retry-After"), assert.NotEmpty(t, rec.Header().Get("Retry-After"), "should include Retry-After header")
"should include Retry-After header")
} }
func TestIPLimiterEvictsStaleEntries(t *testing.T) { func TestIPLimiterEvictsStaleEntries(t *testing.T) {

View File

@@ -7,16 +7,6 @@ import (
"testing" "testing"
) )
// Shared test addresses (also used by ratelimit_test.go).
const (
testProxyAddr = "10.0.0.1:1234"
testRealIP = "203.0.113.5"
testXFFIP = "198.51.100.1"
testPrivateIP = "192.168.1.1"
testPublicIP = "93.184.216.34"
testPublicDNSIP = "8.8.8.8"
)
func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
t.Parallel() t.Parallel()
@@ -30,63 +20,63 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
// === Trusted proxy (RFC1918 / loopback) — headers ARE honoured === // === Trusted proxy (RFC1918 / loopback) — headers ARE honoured ===
{ {
name: "trusted: X-Real-IP from 10.x", name: "trusted: X-Real-IP from 10.x",
remoteAddr: testProxyAddr, remoteAddr: "10.0.0.1:1234",
xRealIP: testRealIP, xRealIP: "203.0.113.5",
xff: "198.51.100.1, 10.0.0.1", xff: "198.51.100.1, 10.0.0.1",
want: testRealIP, want: "203.0.113.5",
}, },
{ {
name: "trusted: XFF from 10.x when no X-Real-IP", name: "trusted: XFF from 10.x when no X-Real-IP",
remoteAddr: testProxyAddr, remoteAddr: "10.0.0.1:1234",
xff: "198.51.100.1, 10.0.0.1", xff: "198.51.100.1, 10.0.0.1",
want: testXFFIP, want: "198.51.100.1",
}, },
{ {
name: "trusted: XFF single IP from 10.x", name: "trusted: XFF single IP from 10.x",
remoteAddr: testProxyAddr, remoteAddr: "10.0.0.1:1234",
xff: "203.0.113.10", xff: "203.0.113.10",
want: "203.0.113.10", want: "203.0.113.10",
}, },
{ {
name: "trusted: falls back to RemoteAddr (192.168.x)", name: "trusted: falls back to RemoteAddr (192.168.x)",
remoteAddr: "192.168.1.1:5678", remoteAddr: "192.168.1.1:5678",
want: testPrivateIP, want: "192.168.1.1",
}, },
{ {
name: "trusted: RemoteAddr without port", name: "trusted: RemoteAddr without port",
remoteAddr: testPrivateIP, remoteAddr: "192.168.1.1",
want: testPrivateIP, want: "192.168.1.1",
}, },
{ {
name: "trusted: X-Real-IP with whitespace from 10.x", name: "trusted: X-Real-IP with whitespace from 10.x",
remoteAddr: testProxyAddr, remoteAddr: "10.0.0.1:1234",
xRealIP: " 203.0.113.5 ", xRealIP: " 203.0.113.5 ",
want: testRealIP, want: "203.0.113.5",
}, },
{ {
name: "trusted: XFF with whitespace from 10.x", name: "trusted: XFF with whitespace from 10.x",
remoteAddr: testProxyAddr, remoteAddr: "10.0.0.1:1234",
xff: " 198.51.100.1 , 10.0.0.1", xff: " 198.51.100.1 , 10.0.0.1",
want: testXFFIP, want: "198.51.100.1",
}, },
{ {
name: "trusted: empty X-Real-IP falls through to XFF from 10.x", name: "trusted: empty X-Real-IP falls through to XFF from 10.x",
remoteAddr: testProxyAddr, remoteAddr: "10.0.0.1:1234",
xRealIP: " ", xRealIP: " ",
xff: testXFFIP, xff: "198.51.100.1",
want: testXFFIP, want: "198.51.100.1",
}, },
{ {
name: "trusted: loopback honours X-Real-IP", name: "trusted: loopback honours X-Real-IP",
remoteAddr: "127.0.0.1:9999", remoteAddr: "127.0.0.1:9999",
xRealIP: testPublicIP, xRealIP: "93.184.216.34",
want: testPublicIP, want: "93.184.216.34",
}, },
{ {
name: "trusted: 172.16.x honours XFF", name: "trusted: 172.16.x honours XFF",
remoteAddr: "172.16.0.1:4321", remoteAddr: "172.16.0.1:4321",
xff: testPublicDNSIP, xff: "8.8.8.8",
want: testPublicDNSIP, want: "8.8.8.8",
}, },
// === Untrusted proxy (public IP) — headers IGNORED, use RemoteAddr === // === Untrusted proxy (public IP) — headers IGNORED, use RemoteAddr ===
@@ -107,17 +97,17 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
remoteAddr: "8.8.8.8:443", remoteAddr: "8.8.8.8:443",
xRealIP: "1.2.3.4", xRealIP: "1.2.3.4",
xff: "5.6.7.8", xff: "5.6.7.8",
want: testPublicDNSIP, want: "8.8.8.8",
}, },
{ {
name: "untrusted: no headers, public RemoteAddr", name: "untrusted: no headers, public RemoteAddr",
remoteAddr: "93.184.216.34:8080", remoteAddr: "93.184.216.34:8080",
want: testPublicIP, want: "93.184.216.34",
}, },
{ {
name: "untrusted: public RemoteAddr without port", name: "untrusted: public RemoteAddr without port",
remoteAddr: testPublicIP, remoteAddr: "93.184.216.34",
want: testPublicIP, want: "93.184.216.34",
}, },
} }
@@ -149,9 +139,7 @@ func TestIsTrustedProxy(t *testing.T) {
trusted := []string{"10.0.0.1", "10.255.255.255", "172.16.0.1", "172.31.255.255", trusted := []string{"10.0.0.1", "10.255.255.255", "172.16.0.1", "172.31.255.255",
"192.168.0.1", "192.168.255.255", "127.0.0.1", "127.255.255.255", "::1"} "192.168.0.1", "192.168.255.255", "127.0.0.1", "127.255.255.255", "::1"}
untrusted := []string{ untrusted := []string{"8.8.8.8", "203.0.113.1", "172.32.0.1", "11.0.0.1", "2001:db8::1"}
testPublicDNSIP, "203.0.113.1", "172.32.0.1", "11.0.0.1", "2001:db8::1",
}
for _, addr := range trusted { for _, addr := range trusted {
ip := net.ParseIP(addr) ip := net.ParseIP(addr)

View File

@@ -7,15 +7,14 @@ import (
"fmt" "fmt"
"time" "time"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// appColumns is the standard column list for app queries. // appColumns is the standard column list for app queries.
const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret, const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status, ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash, docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
previous_image_id, cpu_limit, memory_limit, previous_image_id, created_at, updated_at`
created_at, updated_at`
// AppStatus represents the status of an app. // AppStatus represents the status of an app.
type AppStatus string type AppStatus string
@@ -48,8 +47,6 @@ type App struct {
DockerNetwork sql.NullString DockerNetwork sql.NullString
NtfyTopic sql.NullString NtfyTopic sql.NullString
SlackWebhook sql.NullString SlackWebhook sql.NullString
CPULimit sql.NullFloat64
MemoryLimit sql.NullInt64
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -145,14 +142,14 @@ func (a *App) insert(ctx context.Context) error {
id, name, repo_url, branch, dockerfile_path, webhook_secret, id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status, ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash, docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
previous_image_id, cpu_limit, memory_limit previous_image_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := a.db.Exec(ctx, query, _, err := a.db.Exec(ctx, query,
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret, a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status, a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash,
a.PreviousImageID, a.CPULimit, a.MemoryLimit, a.PreviousImageID,
) )
if err != nil { if err != nil {
return err return err
@@ -168,7 +165,6 @@ func (a *App) update(ctx context.Context) error {
image_id = ?, status = ?, image_id = ?, status = ?,
docker_network = ?, ntfy_topic = ?, slack_webhook = ?, docker_network = ?, ntfy_topic = ?, slack_webhook = ?,
previous_image_id = ?, previous_image_id = ?,
cpu_limit = ?, memory_limit = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ?` WHERE id = ?`
@@ -177,7 +173,6 @@ func (a *App) update(ctx context.Context) error {
a.ImageID, a.Status, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
a.PreviousImageID, a.PreviousImageID,
a.CPULimit, a.MemoryLimit,
a.ID, a.ID,
) )
@@ -193,7 +188,6 @@ func (a *App) scan(row *sql.Row) error {
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook, &a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
&a.WebhookSecretHash, &a.WebhookSecretHash,
&a.PreviousImageID, &a.PreviousImageID,
&a.CPULimit, &a.MemoryLimit,
&a.CreatedAt, &a.UpdatedAt, &a.CreatedAt, &a.UpdatedAt,
) )
} }
@@ -212,7 +206,6 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook, &app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
&app.WebhookSecretHash, &app.WebhookSecretHash,
&app.PreviousImageID, &app.PreviousImageID,
&app.CPULimit, &app.MemoryLimit,
&app.CreatedAt, &app.UpdatedAt, &app.CreatedAt, &app.UpdatedAt,
) )
if scanErr != nil { if scanErr != nil {

View File

@@ -5,10 +5,9 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// DeploymentStatus represents the status of a deployment. // DeploymentStatus represents the status of a deployment.
@@ -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

@@ -1,3 +1,4 @@
//nolint:dupl // Active Record pattern - similar structure to label.go is intentional
package models package models
import ( import (
@@ -6,7 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// EnvVar represents an environment variable for an app. // EnvVar represents an environment variable for an app.
@@ -93,41 +94,6 @@ func FindEnvVar(
return envVar, nil return envVar, nil
} }
// findAllByAppID loads all rows for an app, scanning each row into a
// new model created by newFn. entity names the model in error messages.
func findAllByAppID[T interface{ scanDest() []any }](
ctx context.Context,
db *database.Database,
query, appID, entity string,
newFn func(*database.Database) T,
) ([]T, error) {
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying %s by app: %w", entity, err)
}
defer func() { _ = rows.Close() }()
var items []T
for rows.Next() {
item := newFn(db)
scanErr := rows.Scan(item.scanDest()...)
if scanErr != nil {
return nil, scanErr
}
items = append(items, item)
}
return items, rows.Err()
}
func (e *EnvVar) scanDest() []any {
return []any{&e.ID, &e.AppID, &e.Key, &e.Value}
}
// FindEnvVarsByAppID finds all env vars for an app. // FindEnvVarsByAppID finds all env vars for an app.
func FindEnvVarsByAppID( func FindEnvVarsByAppID(
ctx context.Context, ctx context.Context,
@@ -138,51 +104,38 @@ func FindEnvVarsByAppID(
SELECT id, app_id, key, value FROM app_env_vars SELECT id, app_id, key, value FROM app_env_vars
WHERE app_id = ? ORDER BY key` WHERE app_id = ? ORDER BY key`
return findAllByAppID(ctx, db, query, appID, "env vars", NewEnvVar) rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying env vars by app: %w", err)
}
defer func() { _ = rows.Close() }()
var envVars []*EnvVar
for rows.Next() {
envVar := NewEnvVar(db)
scanErr := rows.Scan(
&envVar.ID, &envVar.AppID, &envVar.Key, &envVar.Value,
)
if scanErr != nil {
return nil, scanErr
}
envVars = append(envVars, envVar)
}
return envVars, rows.Err()
} }
// EnvVarPair is a key-value pair for bulk env var operations. // DeleteEnvVarsByAppID deletes all env vars for an app.
type EnvVarPair struct { func DeleteEnvVarsByAppID(
Key string
Value string
}
// ReplaceEnvVarsByAppID atomically replaces all env vars for an app
// within a single database transaction. It deletes all existing env
// vars and inserts the provided pairs. If any operation fails, the
// entire transaction is rolled back.
func ReplaceEnvVarsByAppID(
ctx context.Context, ctx context.Context,
db *database.Database, db *database.Database,
appID string, appID string,
pairs []EnvVarPair,
) error { ) error {
tx, err := db.BeginTx(ctx, nil) _, err := db.Exec(ctx, "DELETE FROM app_env_vars WHERE app_id = ?", appID)
if err != nil {
return fmt.Errorf("beginning transaction: %w", err)
}
defer func() { _ = tx.Rollback() }() return err
_, err = tx.ExecContext(ctx, "DELETE FROM app_env_vars WHERE app_id = ?", appID)
if err != nil {
return fmt.Errorf("deleting env vars: %w", err)
}
for _, p := range pairs {
_, err = tx.ExecContext(ctx,
"INSERT INTO app_env_vars (app_id, key, value) VALUES (?, ?, ?)",
appID, p.Key, p.Value,
)
if err != nil {
return fmt.Errorf("inserting env var %q: %w", p.Key, err)
}
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("committing transaction: %w", err)
}
return nil
} }

View File

@@ -1,3 +1,4 @@
//nolint:dupl // Active Record pattern - similar structure to env_var.go is intentional
package models package models
import ( import (
@@ -6,7 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// Label represents a Docker label for an app container. // Label represents a Docker label for an app container.
@@ -93,10 +94,6 @@ func FindLabel(
return label, nil return label, nil
} }
func (l *Label) scanDest() []any {
return []any{&l.ID, &l.AppID, &l.Key, &l.Value}
}
// FindLabelsByAppID finds all labels for an app. // FindLabelsByAppID finds all labels for an app.
func FindLabelsByAppID( func FindLabelsByAppID(
ctx context.Context, ctx context.Context,
@@ -107,7 +104,27 @@ func FindLabelsByAppID(
SELECT id, app_id, key, value FROM app_labels SELECT id, app_id, key, value FROM app_labels
WHERE app_id = ? ORDER BY key` WHERE app_id = ? ORDER BY key`
return findAllByAppID(ctx, db, query, appID, "labels", NewLabel) rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying labels by app: %w", err)
}
defer func() { _ = rows.Close() }()
var labels []*Label
for rows.Next() {
label := NewLabel(db)
scanErr := rows.Scan(&label.ID, &label.AppID, &label.Key, &label.Value)
if scanErr != nil {
return nil, scanErr
}
labels = append(labels, label)
}
return labels, rows.Err()
} }
// DeleteLabelsByAppID deletes all labels for an app. // DeleteLabelsByAppID deletes all labels for an app.

View File

@@ -10,11 +10,11 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
) )
// Test constants to satisfy goconst linter. // Test constants to satisfy goconst linter.
@@ -317,16 +317,11 @@ func TestAllApps(t *testing.T) {
// EnvVar Tests. // EnvVar Tests.
// testKVCreateAndFind exercises the create-and-find round trip shared func TestEnvVarCRUD(t *testing.T) {
// by key-value models (env vars, labels). t.Parallel()
func testKVCreateAndFind[T any](
t *testing.T, t.Run("creates and finds env vars", func(t *testing.T) {
wantKey string, t.Parallel()
create func(db *database.Database, appID string) (int64, error),
find func(context.Context, *database.Database, string) ([]T, error),
keyOf func(T) string,
) {
t.Helper()
testDB, cleanup := setupTestDB(t) testDB, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
@@ -334,37 +329,21 @@ func testKVCreateAndFind[T any](
// Create app first. // Create app first.
app := createTestApp(t, testDB) app := createTestApp(t, testDB)
id, err := create(testDB, app.ID) envVar := models.NewEnvVar(testDB)
require.NoError(t, err) envVar.AppID = app.ID
assert.NotZero(t, id)
found, err := find(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.Len(t, found, 1)
assert.Equal(t, wantKey, keyOf(found[0]))
}
func saveTestEnvVar(db *database.Database, appID string) (int64, error) {
envVar := models.NewEnvVar(db)
envVar.AppID = appID
envVar.Key = "DATABASE_URL" envVar.Key = "DATABASE_URL"
envVar.Value = "postgres://localhost/db" envVar.Value = "postgres://localhost/db"
err := envVar.Save(context.Background()) err := envVar.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, envVar.ID)
return envVar.ID, err envVars, err := models.FindEnvVarsByAppID(
} context.Background(), testDB, app.ID,
func TestEnvVarCRUD(t *testing.T) {
t.Parallel()
t.Run("creates and finds env vars", func(t *testing.T) {
t.Parallel()
testKVCreateAndFind(t, "DATABASE_URL", saveTestEnvVar,
models.FindEnvVarsByAppID,
func(e *models.EnvVar) string { return e.Key },
) )
require.NoError(t, err)
require.Len(t, envVars, 1)
assert.Equal(t, "DATABASE_URL", envVars[0].Key)
}) })
t.Run("deletes env var", func(t *testing.T) { t.Run("deletes env var", func(t *testing.T) {
@@ -396,27 +375,32 @@ func TestEnvVarCRUD(t *testing.T) {
// Label Tests. // Label Tests.
func saveTestLabel(db *database.Database, appID string) (int64, error) {
label := models.NewLabel(db)
label.AppID = appID
label.Key = "traefik.enable"
label.Value = "true"
err := label.Save(context.Background())
return label.ID, err
}
func TestLabelCRUD(t *testing.T) { func TestLabelCRUD(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("creates and finds labels", func(t *testing.T) { t.Run("creates and finds labels", func(t *testing.T) {
t.Parallel() t.Parallel()
testKVCreateAndFind(t, "traefik.enable", saveTestLabel, testDB, cleanup := setupTestDB(t)
models.FindLabelsByAppID, defer cleanup()
func(l *models.Label) string { return l.Key },
app := createTestApp(t, testDB)
label := models.NewLabel(testDB)
label.AppID = app.ID
label.Key = "traefik.enable"
label.Value = "true"
err := label.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, label.ID)
labels, err := models.FindLabelsByAppID(
context.Background(), testDB, app.ID,
) )
require.NoError(t, err)
require.Len(t, labels, 1)
assert.Equal(t, "traefik.enable", labels[0].Key)
}) })
} }
@@ -585,9 +569,7 @@ func TestDeploymentFindByAppID(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
deployments, err := models.FindDeploymentsByAppID( deployments, err := models.FindDeploymentsByAppID(context.Background(), testDB, app.ID, 3)
context.Background(), testDB, app.ID, 3,
)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, deployments, 3) assert.Len(t, deployments, 3)
} }
@@ -724,6 +706,7 @@ func TestAppGetWebhookEvents(t *testing.T) {
// Cascade Delete Tests. // Cascade Delete Tests.
//nolint:funlen // Test function with many assertions - acceptable for integration tests
func TestCascadeDelete(t *testing.T) { func TestCascadeDelete(t *testing.T) {
t.Parallel() t.Parallel()
@@ -798,97 +781,6 @@ func TestCascadeDelete(t *testing.T) {
}) })
} }
// Resource Limits Tests.
//nolint:funlen // integration test with multiple subtests
func TestAppResourceLimits(t *testing.T) {
t.Parallel()
t.Run("saves and loads CPU limit", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
app.CPULimit = sql.NullFloat64{Float64: 0.5, Valid: true}
err := app.Save(context.Background())
require.NoError(t, err)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.True(t, found.CPULimit.Valid)
assert.InDelta(t, 0.5, found.CPULimit.Float64, 0.001)
})
t.Run("saves and loads memory limit", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
app.MemoryLimit = sql.NullInt64{Int64: 536870912, Valid: true} // 512m
err := app.Save(context.Background())
require.NoError(t, err)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.True(t, found.MemoryLimit.Valid)
assert.Equal(t, int64(536870912), found.MemoryLimit.Int64)
})
t.Run("null limits by default", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.False(t, found.CPULimit.Valid)
assert.False(t, found.MemoryLimit.Valid)
})
t.Run("clears limits when set to null", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
// Set limits
app.CPULimit = sql.NullFloat64{Float64: 1.0, Valid: true}
app.MemoryLimit = sql.NullInt64{Int64: 1073741824, Valid: true} // 1g
err := app.Save(context.Background())
require.NoError(t, err)
// Clear limits
app.CPULimit = sql.NullFloat64{}
app.MemoryLimit = sql.NullInt64{}
err = app.Save(context.Background())
require.NoError(t, err)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.False(t, found.CPULimit.Valid)
assert.False(t, found.MemoryLimit.Valid)
})
}
// Helper function to create a test app. // Helper function to create a test app.
func createTestApp(t *testing.T, testDB *database.Database) *models.App { func createTestApp(t *testing.T, testDB *database.Database) *models.App {
t.Helper() t.Helper()

View File

@@ -6,7 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// PortProtocol represents the protocol for a port mapping. // PortProtocol represents the protocol for a port mapping.
@@ -112,12 +112,6 @@ func FindPort(
return port, nil return port, nil
} }
func (p *Port) scanDest() []any {
return []any{
&p.ID, &p.AppID, &p.HostPort, &p.ContainerPort, &p.Protocol,
}
}
// FindPortsByAppID finds all ports for an app. // FindPortsByAppID finds all ports for an app.
func FindPortsByAppID( func FindPortsByAppID(
ctx context.Context, ctx context.Context,
@@ -128,7 +122,30 @@ func FindPortsByAppID(
SELECT id, app_id, host_port, container_port, protocol SELECT id, app_id, host_port, container_port, protocol
FROM app_ports WHERE app_id = ? ORDER BY host_port` FROM app_ports WHERE app_id = ? ORDER BY host_port`
return findAllByAppID(ctx, db, query, appID, "ports", NewPort) rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying ports by app: %w", err)
}
defer func() { _ = rows.Close() }()
var ports []*Port
for rows.Next() {
port := NewPort(db)
scanErr := rows.Scan(
&port.ID, &port.AppID, &port.HostPort,
&port.ContainerPort, &port.Protocol,
)
if scanErr != nil {
return nil, scanErr
}
ports = append(ports, port)
}
return ports, rows.Err()
} }
// DeletePortsByAppID deletes all ports for an app. // DeletePortsByAppID deletes all ports for an app.

View File

@@ -8,7 +8,7 @@ import (
"fmt" "fmt"
"time" "time"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// User represents a user in the system. // User represents a user in the system.

View File

@@ -6,7 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// Volume represents a volume mount for an app container. // Volume represents a volume mount for an app container.
@@ -103,12 +103,6 @@ func FindVolume(
return vol, nil return vol, nil
} }
func (v *Volume) scanDest() []any {
return []any{
&v.ID, &v.AppID, &v.HostPath, &v.ContainerPath, &v.ReadOnly,
}
}
// FindVolumesByAppID finds all volumes for an app. // FindVolumesByAppID finds all volumes for an app.
func FindVolumesByAppID( func FindVolumesByAppID(
ctx context.Context, ctx context.Context,
@@ -119,7 +113,30 @@ func FindVolumesByAppID(
SELECT id, app_id, host_path, container_path, readonly SELECT id, app_id, host_path, container_path, readonly
FROM app_volumes WHERE app_id = ? ORDER BY container_path` FROM app_volumes WHERE app_id = ? ORDER BY container_path`
return findAllByAppID(ctx, db, query, appID, "volumes", NewVolume) rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying volumes by app: %w", err)
}
defer func() { _ = rows.Close() }()
var volumes []*Volume
for rows.Next() {
vol := NewVolume(db)
scanErr := rows.Scan(
&vol.ID, &vol.AppID, &vol.HostPath,
&vol.ContainerPath, &vol.ReadOnly,
)
if scanErr != nil {
return nil, scanErr
}
volumes = append(volumes, vol)
}
return volumes, rows.Err()
} }
// DeleteVolumesByAppID deletes all volumes for an app. // DeleteVolumesByAppID deletes all volumes for an app.

View File

@@ -7,7 +7,7 @@ import (
"fmt" "fmt"
"time" "time"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// WebhookEvent represents a received webhook event. // WebhookEvent represents a received webhook event.
@@ -52,20 +52,6 @@ func (w *WebhookEvent) Reload(ctx context.Context) error {
return w.scan(row) return w.scan(row)
} }
// ShortCommit returns a truncated commit SHA for display.
func (w *WebhookEvent) ShortCommit() string {
if !w.CommitSHA.Valid {
return ""
}
sha := w.CommitSHA.String
if len(sha) > shortCommitLength {
return sha[:shortCommitLength]
}
return sha
}
func (w *WebhookEvent) insert(ctx context.Context) error { func (w *WebhookEvent) insert(ctx context.Context) error {
query := ` query := `
INSERT INTO webhook_events ( INSERT INTO webhook_events (

View File

@@ -8,7 +8,7 @@ import (
chimw "github.com/go-chi/chi/v5/middleware" chimw "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"sneak.berlin/go/upaas/static" "git.eeqj.de/sneak/upaas/static"
) )
// requestTimeout is the maximum duration for handling a request. // requestTimeout is the maximum duration for handling a request.
@@ -70,15 +70,8 @@ func (s *Server) SetupRoutes() {
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}/webhooks", s.handlers.HandleAppWebhookEvents()) r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
r.Get( r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
"/apps/{id}/deployments/{deploymentID}/logs",
s.handlers.HandleDeploymentLogsAPI(),
)
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())
@@ -88,8 +81,10 @@ func (s *Server) SetupRoutes() {
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 (monolithic bulk save) // Environment variables
r.Post("/apps/{id}/env", s.handlers.HandleEnvVarSave()) r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
// Labels // Labels
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd()) r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
@@ -119,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

@@ -12,11 +12,11 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/handlers" "git.eeqj.de/sneak/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/middleware" "git.eeqj.de/sneak/upaas/internal/middleware"
) )
// Params contains dependencies for Server. // Params contains dependencies for Server.

View File

@@ -14,10 +14,10 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/ssh" "git.eeqj.de/sneak/upaas/internal/ssh"
) )
// ServiceParams contains dependencies for Service. // ServiceParams contains dependencies for Service.

View File

@@ -8,20 +8,14 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/internal/service/app"
) )
// testRepoURL is the default repository URL used across tests.
const testRepoURL = "git@example.com:user/repo.git"
// giteaRepoURL is the gitea repository URL used across tests.
const giteaRepoURL = "git@gitea.example.com:user/repo.git"
func setupTestService(t *testing.T) (*app.Service, func()) { func setupTestService(t *testing.T) (*app.Service, func()) {
t.Helper() t.Helper()
@@ -64,8 +58,7 @@ func setupTestService(t *testing.T) (*app.Service, func()) {
} }
// deleteItemTestHelper is a generic helper for testing delete operations. // deleteItemTestHelper is a generic helper for testing delete operations.
// It creates an app, adds an item, verifies it exists, deletes it, and // It creates an app, adds an item, verifies it exists, deletes it, and verifies it's gone.
// verifies it's gone.
func deleteItemTestHelper( func deleteItemTestHelper(
t *testing.T, t *testing.T,
appName string, appName string,
@@ -80,7 +73,7 @@ func deleteItemTestHelper(
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{ createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: appName, Name: appName,
RepoURL: testRepoURL, RepoURL: "git@example.com:user/repo.git",
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -99,35 +92,6 @@ func deleteItemTestHelper(
assert.Equal(t, 0, count) assert.Equal(t, 0, count)
} }
// runDeleteItemTest adapts typed list/delete callbacks so delete tests for
// different item types can share deleteItemTestHelper.
func runDeleteItemTest[T any](
t *testing.T,
appName string,
addItem func(ctx context.Context, svc *app.Service, appID string) error,
listItems func(ctx context.Context, application *models.App) ([]T, error),
deleteFirst func(ctx context.Context, svc *app.Service, item T) error,
) {
t.Helper()
deleteItemTestHelper(t, appName,
addItem,
func(ctx context.Context, application *models.App) (int, error) {
items, err := listItems(ctx, application)
return len(items), err
},
func(ctx context.Context, svc *app.Service, application *models.App) error {
items, err := listItems(ctx, application)
if err != nil {
return err
}
return deleteFirst(ctx, svc, items[0])
},
)
}
func TestCreateAppWithGeneratedKeys(t *testing.T) { func TestCreateAppWithGeneratedKeys(t *testing.T) {
t.Parallel() t.Parallel()
@@ -136,7 +100,7 @@ func TestCreateAppWithGeneratedKeys(t *testing.T) {
input := app.CreateAppInput{ input := app.CreateAppInput{
Name: "test-app", Name: "test-app",
RepoURL: giteaRepoURL, RepoURL: "git@gitea.example.com:user/repo.git",
Branch: "main", Branch: "main",
DockerfilePath: "Dockerfile", DockerfilePath: "Dockerfile",
} }
@@ -146,7 +110,7 @@ func TestCreateAppWithGeneratedKeys(t *testing.T) {
require.NotNil(t, createdApp) require.NotNil(t, createdApp)
assert.Equal(t, "test-app", createdApp.Name) assert.Equal(t, "test-app", createdApp.Name)
assert.Equal(t, giteaRepoURL, createdApp.RepoURL) assert.Equal(t, "git@gitea.example.com:user/repo.git", createdApp.RepoURL)
assert.Equal(t, "main", createdApp.Branch) assert.Equal(t, "main", createdApp.Branch)
assert.Equal(t, "Dockerfile", createdApp.DockerfilePath) assert.Equal(t, "Dockerfile", createdApp.DockerfilePath)
assert.NotEmpty(t, createdApp.ID) assert.NotEmpty(t, createdApp.ID)
@@ -166,7 +130,7 @@ func TestCreateAppDefaults(t *testing.T) {
input := app.CreateAppInput{ input := app.CreateAppInput{
Name: "test-app-defaults", Name: "test-app-defaults",
RepoURL: giteaRepoURL, RepoURL: "git@gitea.example.com:user/repo.git",
} }
createdApp, err := svc.CreateApp(context.Background(), input) createdApp, err := svc.CreateApp(context.Background(), input)
@@ -184,7 +148,7 @@ func TestCreateAppOptionalFields(t *testing.T) {
input := app.CreateAppInput{ input := app.CreateAppInput{
Name: "test-app-full", Name: "test-app-full",
RepoURL: giteaRepoURL, RepoURL: "git@gitea.example.com:user/repo.git",
Branch: "develop", Branch: "develop",
DockerNetwork: "my-network", DockerNetwork: "my-network",
NtfyTopic: "https://ntfy.sh/my-topic", NtfyTopic: "https://ntfy.sh/my-topic",
@@ -212,7 +176,7 @@ func TestUpdateApp(testingT *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{ createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "original-name", Name: "original-name",
RepoURL: testRepoURL, RepoURL: "git@example.com:user/repo.git",
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -244,7 +208,7 @@ func TestUpdateApp(testingT *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{ createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "test-clear", Name: "test-clear",
RepoURL: testRepoURL, RepoURL: "git@example.com:user/repo.git",
NtfyTopic: "https://ntfy.sh/topic", NtfyTopic: "https://ntfy.sh/topic",
SlackWebhook: "https://slack.com/hook", SlackWebhook: "https://slack.com/hook",
}) })
@@ -252,7 +216,7 @@ func TestUpdateApp(testingT *testing.T) {
err = svc.UpdateApp(context.Background(), createdApp, app.UpdateAppInput{ err = svc.UpdateApp(context.Background(), createdApp, app.UpdateAppInput{
Name: "test-clear", Name: "test-clear",
RepoURL: testRepoURL, RepoURL: "git@example.com:user/repo.git",
Branch: "main", Branch: "main",
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -276,7 +240,7 @@ func TestDeleteApp(testingT *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{ createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "to-delete", Name: "to-delete",
RepoURL: testRepoURL, RepoURL: "git@example.com:user/repo.git",
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -300,7 +264,7 @@ func TestGetApp(testingT *testing.T) {
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{ created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "findable-app", Name: "findable-app",
RepoURL: testRepoURL, RepoURL: "git@example.com:user/repo.git",
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -335,7 +299,7 @@ func TestGetAppByWebhookSecret(testingT *testing.T) {
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{ created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "webhook-app", Name: "webhook-app",
RepoURL: testRepoURL, RepoURL: "git@example.com:user/repo.git",
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -414,7 +378,7 @@ func TestEnvVarsAddAndRetrieve(t *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{ createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "env-test", Name: "env-test",
RepoURL: testRepoURL, RepoURL: "git@example.com:user/repo.git",
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -447,33 +411,29 @@ func TestEnvVarsAddAndRetrieve(t *testing.T) {
assert.Equal(t, "secret123", keys["API_KEY"]) assert.Equal(t, "secret123", keys["API_KEY"])
} }
// addDeletableEnvVar seeds the env var removed in the delete test.
func addDeletableEnvVar(
ctx context.Context, svc *app.Service, appID string,
) error {
return svc.AddEnvVar(ctx, appID, "TO_DELETE", "value")
}
func TestEnvVarsDelete(t *testing.T) { func TestEnvVarsDelete(t *testing.T) {
t.Parallel() t.Parallel()
runDeleteItemTest(t, "env-delete-test", addDeletableEnvVar, deleteItemTestHelper(t, "env-delete-test",
func(ctx context.Context, application *models.App) ([]*models.EnvVar, error) { func(ctx context.Context, svc *app.Service, appID string) error {
return application.GetEnvVars(ctx) return svc.AddEnvVar(ctx, appID, "TO_DELETE", "value")
}, },
func(ctx context.Context, svc *app.Service, item *models.EnvVar) error { func(ctx context.Context, application *models.App) (int, error) {
return svc.DeleteEnvVar(ctx, item.ID) envVars, err := application.GetEnvVars(ctx)
return len(envVars), err
},
func(ctx context.Context, svc *app.Service, application *models.App) error {
envVars, err := application.GetEnvVars(ctx)
if err != nil {
return err
}
return svc.DeleteEnvVar(ctx, envVars[0].ID)
}, },
) )
} }
// addDeletableLabel seeds the label removed in the delete test.
func addDeletableLabel(
ctx context.Context, svc *app.Service, appID string,
) error {
return svc.AddLabel(ctx, appID, "to.delete", "value")
}
func TestLabels(testingT *testing.T) { func TestLabels(testingT *testing.T) {
testingT.Parallel() testingT.Parallel()
@@ -485,7 +445,7 @@ func TestLabels(testingT *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{ createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "label-test", Name: "label-test",
RepoURL: testRepoURL, RepoURL: "git@example.com:user/repo.git",
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -508,12 +468,22 @@ func TestLabels(testingT *testing.T) {
testingT.Run("deletes label", func(t *testing.T) { testingT.Run("deletes label", func(t *testing.T) {
t.Parallel() t.Parallel()
runDeleteItemTest(t, "label-delete-test", addDeletableLabel, deleteItemTestHelper(t, "label-delete-test",
func(ctx context.Context, application *models.App) ([]*models.Label, error) { func(ctx context.Context, svc *app.Service, appID string) error {
return application.GetLabels(ctx) return svc.AddLabel(ctx, appID, "to.delete", "value")
}, },
func(ctx context.Context, svc *app.Service, item *models.Label) error { func(ctx context.Context, application *models.App) (int, error) {
return svc.DeleteLabel(ctx, item.ID) labels, err := application.GetLabels(ctx)
return len(labels), err
},
func(ctx context.Context, svc *app.Service, application *models.App) error {
labels, err := application.GetLabels(ctx)
if err != nil {
return err
}
return svc.DeleteLabel(ctx, labels[0].ID)
}, },
) )
}) })
@@ -527,7 +497,7 @@ func TestVolumesAddAndRetrieve(t *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{ createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "volume-test", Name: "volume-test",
RepoURL: testRepoURL, RepoURL: "git@example.com:user/repo.git",
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -577,7 +547,7 @@ func TestVolumesDelete(t *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{ createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "volume-delete-test", Name: "volume-delete-test",
RepoURL: testRepoURL, RepoURL: "git@example.com:user/repo.git",
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -613,7 +583,7 @@ func TestUpdateAppStatus(testingT *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{ createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "status-test", Name: "status-test",
RepoURL: testRepoURL, RepoURL: "git@example.com:user/repo.git",
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, models.AppStatusPending, createdApp.Status) assert.Equal(t, models.AppStatusPending, createdApp.Status)

View File

@@ -15,10 +15,10 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
) )
const ( const (

View File

@@ -12,11 +12,11 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/auth"
) )
func setupTestService(t *testing.T) (*auth.Service, func()) { func setupTestService(t *testing.T) (*auth.Service, func()) {
@@ -121,7 +121,7 @@ func getSessionCookie(t *testing.T, svc *auth.Service) *http.Cookie {
require.NoError(t, err) require.NoError(t, err)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) request := httptest.NewRequest(http.MethodGet, "/", nil)
err = svc.CreateSession(recorder, request, user) err = svc.CreateSession(recorder, request, user)
require.NoError(t, err) require.NoError(t, err)
@@ -144,11 +144,7 @@ func TestSessionCookieSecureFlag(testingT *testing.T) {
svc := setupAuthService(t, false) svc := setupAuthService(t, false)
cookie := getSessionCookie(t, svc) cookie := getSessionCookie(t, svc)
require.NotNil(t, cookie, "session cookie should exist") require.NotNil(t, cookie, "session cookie should exist")
assert.True( assert.True(t, cookie.Secure, "session cookie should have Secure flag in production mode")
t,
cookie.Secure,
"session cookie should have Secure flag in production mode",
)
}) })
} }
@@ -328,12 +324,7 @@ func TestCreateUserRaceCondition(testingT *testing.T) {
} }
assert.Equal(t, 1, successes, "exactly one goroutine should succeed") assert.Equal(t, 1, successes, "exactly one goroutine should succeed")
assert.Equal( assert.Equal(t, goroutines-1, failures, "all other goroutines should fail with ErrUserExists")
t,
goroutines-1,
failures,
"all other goroutines should fail with ErrUserExists",
)
}) })
} }
@@ -389,9 +380,7 @@ func TestDestroySessionMaxAge(testingT *testing.T) {
defer cleanup() defer cleanup()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext( request := httptest.NewRequest(http.MethodGet, "/", nil)
t.Context(), http.MethodGet, "/", nil,
)
err := svc.DestroySession(recorder, request) err := svc.DestroySession(recorder, request)
require.NoError(t, err) require.NoError(t, err)

View File

@@ -11,18 +11,17 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker" "git.eeqj.de/sneak/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/notify" "git.eeqj.de/sneak/upaas/internal/service/notify"
) )
// Time constants. // Time constants.
@@ -66,8 +65,7 @@ const logFilePermissions = 0o640
// logTimestampFormat is the format for log file timestamps. // logTimestampFormat is the format for log file timestamps.
const logTimestampFormat = "20060102T150405Z" const logTimestampFormat = "20060102T150405Z"
// logFileShortSHALength is the number of characters to use for commit SHA // logFileShortSHALength is the number of characters to use for commit SHA in log filenames.
// in log filenames.
const logFileShortSHALength = 12 const logFileShortSHALength = 12
// dockerLogMessage represents a Docker build log message. // dockerLogMessage represents a Docker build log message.
@@ -88,10 +86,7 @@ type deploymentLogWriter struct {
flushCtx context.Context //nolint:containedctx // needed for async flush goroutine flushCtx context.Context //nolint:containedctx // needed for async flush goroutine
} }
func newDeploymentLogWriter( func newDeploymentLogWriter(ctx context.Context, deployment *models.Deployment) *deploymentLogWriter {
ctx context.Context,
deployment *models.Deployment,
) *deploymentLogWriter {
w := &deploymentLogWriter{ w := &deploymentLogWriter{
deployment: deployment, deployment: deployment,
done: make(chan struct{}), done: make(chan struct{}),
@@ -255,16 +250,13 @@ 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.
// Returns empty string if the path cannot be determined. // Returns empty string if the path cannot be determined.
func (svc *Service) GetLogFilePath( func (svc *Service) GetLogFilePath(app *models.App, deployment *models.Deployment) string {
app *models.App,
deployment *models.Deployment,
) string {
hostname, err := os.Hostname() hostname, err := os.Hostname()
if err != nil { if err != nil {
hostname = "unknown" hostname = "unknown"
@@ -282,8 +274,7 @@ func (svc *Service) GetLogFilePath(
// Use started_at timestamp // Use started_at timestamp
timestamp := deployment.StartedAt.UTC().Format(logTimestampFormat) timestamp := deployment.StartedAt.UTC().Format(logTimestampFormat)
// Build filename: appname_sha_timestamp.log.txt // Build filename: appname_sha_timestamp.log.txt (or appname_timestamp.log.txt if no SHA)
// (or appname_timestamp.log.txt if no SHA)
var filename string var filename string
if sha != "" { if sha != "" {
filename = fmt.Sprintf("%s_%s_%s.log.txt", app.Name, sha, timestamp) filename = fmt.Sprintf("%s_%s_%s.log.txt", app.Name, sha, timestamp)
@@ -316,8 +307,7 @@ func (svc *Service) CancelDeploy(appID string) bool {
// Deploy deploys an app. If cancelExisting is true (e.g. webhook-triggered), // Deploy deploys an app. If cancelExisting is true (e.g. webhook-triggered),
// any in-progress deploy for the same app will be cancelled before starting. // any in-progress deploy for the same app will be cancelled before starting.
// If cancelExisting is false and a deploy is in progress, // If cancelExisting is false and a deploy is in progress, ErrDeploymentInProgress is returned.
// ErrDeploymentInProgress is returned.
func (svc *Service) Deploy( func (svc *Service) Deploy(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
@@ -351,8 +341,7 @@ func (svc *Service) Deploy(
// Fetch webhook event and create deployment record // Fetch webhook event and create deployment record
webhookEvent := svc.fetchWebhookEvent(deployCtx, webhookEventID) webhookEvent := svc.fetchWebhookEvent(deployCtx, webhookEventID)
// Use a background context for DB operations that must complete // Use a background context for DB operations that must complete regardless of cancellation
// regardless of cancellation
bgCtx := context.WithoutCancel(deployCtx) bgCtx := context.WithoutCancel(deployCtx)
deployment, err := svc.createDeploymentRecord(bgCtx, app, webhookEventID, webhookEvent) deployment, err := svc.createDeploymentRecord(bgCtx, app, webhookEventID, webhookEvent)
@@ -411,10 +400,7 @@ func (svc *Service) createRollbackDeployment(
return nil, fmt.Errorf("failed to create rollback deployment: %w", saveErr) return nil, fmt.Errorf("failed to create rollback deployment: %w", saveErr)
} }
_ = deployment.AppendLog( _ = deployment.AppendLog(ctx, "Rolling back to previous image: "+app.PreviousImageID.String)
ctx,
"Rolling back to previous image: "+app.PreviousImageID.String,
)
return deployment, nil return deployment, nil
} }
@@ -430,40 +416,28 @@ func (svc *Service) executeRollback(
svc.removeOldContainer(ctx, app, deployment) svc.removeOldContainer(ctx, app, deployment)
rollbackOpts, err := svc.buildContainerOptions( rollbackOpts, err := svc.buildContainerOptions(ctx, app, deployment.ID)
ctx,
app,
docker.ImageID(previousImageID),
)
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( svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to create rollback container: %w", err))
bgCtx,
app,
deployment,
fmt.Errorf("failed to create rollback container: %w", err),
)
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 {
svc.failDeployment( svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to start rollback container: %w", startErr))
bgCtx,
app,
deployment,
fmt.Errorf("failed to start rollback container: %w", startErr),
)
return fmt.Errorf("failed to start rollback container: %w", startErr) return fmt.Errorf("failed to start rollback container: %w", startErr)
} }
@@ -498,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
} }
@@ -511,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
} }
@@ -541,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()
@@ -566,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()
@@ -687,81 +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,
@@ -847,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
@@ -881,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
} }
@@ -901,24 +818,14 @@ func (svc *Service) cloneRepository(
err := os.MkdirAll(appBuildsDir, buildsDirPermissions) err := os.MkdirAll(appBuildsDir, buildsDirPermissions)
if err != nil { if err != nil {
svc.failDeployment( svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create builds dir: %w", err))
ctx,
app,
deployment,
fmt.Errorf("failed to create builds dir: %w", err),
)
return "", nil, fmt.Errorf("failed to create builds dir: %w", err) return "", nil, fmt.Errorf("failed to create builds dir: %w", err)
} }
buildDir, err := os.MkdirTemp(appBuildsDir, fmt.Sprintf("%d-*", deployment.ID)) buildDir, err := os.MkdirTemp(appBuildsDir, fmt.Sprintf("%d-*", deployment.ID))
if err != nil { if err != nil {
svc.failDeployment( svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create temp dir: %w", err))
ctx,
app,
deployment,
fmt.Errorf("failed to create temp dir: %w", err),
)
return "", nil, fmt.Errorf("failed to create temp dir: %w", err) return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
} }
@@ -949,12 +856,7 @@ func (svc *Service) cloneRepository(
) )
if cloneErr != nil { if cloneErr != nil {
cleanup() cleanup()
svc.failDeployment( svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to clone repo: %w", cloneErr))
ctx,
app,
deployment,
fmt.Errorf("failed to clone repo: %w", cloneErr),
)
return "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr) return "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr)
} }
@@ -1055,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)
@@ -1084,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 {
@@ -1108,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 {
@@ -1140,28 +1042,14 @@ func (svc *Service) buildContainerOptions(
network = app.DockerNetwork.String network = app.DockerNetwork.String
} }
var cpuLimit float64
if app.CPULimit.Valid {
cpuLimit = app.CPULimit.Float64
}
var memoryLimit int64
if app.MemoryLimit.Valid {
memoryLimit = app.MemoryLimit.Int64
}
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),
Ports: buildPortMappings(ports), Ports: buildPortMappings(ports),
Network: network, Network: network,
CPULimit: cpuLimit,
MemoryLimit: memoryLimit,
}, nil }, nil
} }
@@ -1206,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

@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"sneak.berlin/go/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
) )
func TestCancelActiveDeploy_NoExisting(t *testing.T) { func TestCancelActiveDeploy_NoExisting(t *testing.T) {

View File

@@ -1,66 +0,0 @@
package deploy_test
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/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,137 +0,0 @@
package deploy_test
import (
"context"
"database/sql"
"log/slog"
"os"
"testing"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/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)
}
}
func TestBuildContainerOptionsNoResourceLimits(t *testing.T) {
t.Parallel()
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = "nolimits"
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)
opts, err := svc.BuildContainerOptionsExported(
context.Background(), app, docker.ImageID("test:latest"),
)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
if opts.CPULimit != 0 {
t.Errorf("expected CPULimit=0, got %v", opts.CPULimit)
}
if opts.MemoryLimit != 0 {
t.Errorf("expected MemoryLimit=0, got %v", opts.MemoryLimit)
}
}
// buildOptsForApp saves an app configured by setup and returns the container
// options built for it.
func buildOptsForApp(
t *testing.T,
name string,
setup func(app *models.App),
) docker.CreateContainerOptions {
t.Helper()
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = name
setup(app)
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)
opts, err := svc.BuildContainerOptionsExported(
context.Background(), app, docker.ImageID("test:latest"),
)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
return opts
}
func TestBuildContainerOptionsCPULimit(t *testing.T) {
t.Parallel()
opts := buildOptsForApp(t, "cpulimit", func(app *models.App) {
app.CPULimit = sql.NullFloat64{Float64: 0.5, Valid: true}
})
if opts.CPULimit != 0.5 {
t.Errorf("expected CPULimit=0.5, got %v", opts.CPULimit)
}
}
func TestBuildContainerOptionsMemoryLimit(t *testing.T) {
t.Parallel()
opts := buildOptsForApp(t, "memlimit", func(app *models.App) {
app.MemoryLimit = sql.NullInt64{Int64: 536870912, Valid: true} // 512m
})
if opts.MemoryLimit != 536870912 {
t.Errorf("expected MemoryLimit=536870912, got %v", opts.MemoryLimit)
}
}

View File

@@ -2,15 +2,7 @@ package deploy
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"os"
"path/filepath"
"strings"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/models"
) )
// NewTestService creates a Service with minimal dependencies for testing. // NewTestService creates a Service with minimal dependencies for testing.
@@ -26,11 +18,7 @@ func (svc *Service) CancelActiveDeploy(appID string) {
} }
// RegisterActiveDeploy registers an active deploy for testing. // RegisterActiveDeploy registers an active deploy for testing.
func (svc *Service) RegisterActiveDeploy( func (svc *Service) RegisterActiveDeploy(appID string, cancel context.CancelFunc, done chan struct{}) {
appID string,
cancel context.CancelFunc,
done chan struct{},
) {
svc.activeDeploys.Store(appID, &activeDeploy{cancel: cancel, done: done}) svc.activeDeploys.Store(appID, &activeDeploy{cancel: cancel, done: done})
} }
@@ -43,58 +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,13 +10,12 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
) )
// HTTP client timeout. // HTTP client timeout.
@@ -159,8 +158,7 @@ func (svc *Service) NotifyDeployFailed(
) { ) {
duration := time.Since(deployment.StartedAt) duration := time.Since(deployment.StartedAt)
title := "Deploy failed: " + app.Name title := "Deploy failed: " + app.Name
message := "Deployment failed after " + formatDuration(duration) + message := "Deployment failed after " + formatDuration(duration) + ": " + deployErr.Error()
": " + deployErr.Error()
svc.sendNotifications(ctx, app, title, message, message, "error") svc.sendNotifications(ctx, app, title, message, message, "error")
} }
@@ -249,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 {
@@ -267,7 +260,6 @@ 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))
// #nosec G704 -- URL from validated config, not user input
resp, err := svc.client.Do(request) 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)
@@ -348,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 {
@@ -365,7 +352,6 @@ func (svc *Service) sendSlack(
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
// #nosec G704 -- URL from validated config, not user input
resp, err := svc.client.Do(request) 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,237 +0,0 @@
package webhook
import "encoding/json"
// GiteaPushPayload represents a Gitea push webhook payload.
//
//nolint:tagliatelle // Field names match Gitea API (snake_case)
type GiteaPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL UnparsedURL `json:"compare_url"`
Repository struct {
FullName string `json:"full_name"`
CloneURL UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL UnparsedURL `json:"html_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
Email string `json:"email"`
} `json:"pusher"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// GitHubPushPayload represents a GitHub push webhook payload.
//
//nolint:tagliatelle // Field names match GitHub API (snake_case)
type GitHubPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL string `json:"compare"`
Repository struct {
FullName string `json:"full_name"`
CloneURL UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL UnparsedURL `json:"html_url"`
} `json:"repository"`
Pusher struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"pusher"`
HeadCommit *struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
} `json:"head_commit"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// GitLabPushPayload represents a GitLab push webhook payload.
//
//nolint:tagliatelle // Field names match GitLab API (snake_case)
type GitLabPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
Project struct {
PathWithNamespace string `json:"path_with_namespace"`
GitHTTPURL UnparsedURL `json:"git_http_url"`
GitSSHURL string `json:"git_ssh_url"`
WebURL UnparsedURL `json:"web_url"`
} `json:"project"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// ParsePushPayload parses a raw webhook payload into a normalized PushEvent
// based on the detected webhook source. Returns an error if JSON unmarshaling
// fails. For SourceUnknown, falls back to Gitea format for backward
// compatibility.
func ParsePushPayload(source Source, payload []byte) (*PushEvent, error) {
switch source {
case SourceGitHub:
return parsePush(payload, githubPushEvent)
case SourceGitLab:
return parsePush(payload, gitlabPushEvent)
case SourceGitea, SourceUnknown:
// Gitea and unknown both use Gitea format for backward compatibility.
return parsePush(payload, giteaPushEvent)
}
// Unreachable for known source values, but satisfies exhaustive checker.
return parsePush(payload, giteaPushEvent)
}
// parsePush unmarshals payload into P and converts it into a normalized
// PushEvent via build.
func parsePush[P any](payload []byte, build func(P) *PushEvent) (*PushEvent, error) {
var p P
unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil {
return nil, unmarshalErr
}
return build(p), nil
}
// basePushEvent builds a PushEvent populated with the fields shared by all
// webhook sources.
func basePushEvent(source Source, ref, before, after string) *PushEvent {
return &PushEvent{
Source: source,
Ref: ref,
Before: before,
After: after,
Branch: extractBranch(ref),
}
}
// giteaPushEvent converts a Gitea push payload to a normalized PushEvent.
func giteaPushEvent(p GiteaPushPayload) *PushEvent {
event := basePushEvent(SourceGitea, p.Ref, p.Before, p.After)
event.RepoName = p.Repository.FullName
event.CloneURL = p.Repository.CloneURL
event.HTMLURL = p.Repository.HTMLURL
event.CommitURL = extractGiteaCommitURL(p)
event.Pusher = p.Pusher.Username
return event
}
// gitlabPushEvent converts a GitLab push payload to a normalized PushEvent.
func gitlabPushEvent(p GitLabPushPayload) *PushEvent {
event := basePushEvent(SourceGitLab, p.Ref, p.Before, p.After)
event.RepoName = p.Project.PathWithNamespace
event.CloneURL = p.Project.GitHTTPURL
event.HTMLURL = p.Project.WebURL
event.CommitURL = extractGitLabCommitURL(p)
event.Pusher = p.UserName
return event
}
// githubPushEvent converts a GitHub push payload to a normalized PushEvent.
func githubPushEvent(p GitHubPushPayload) *PushEvent {
event := basePushEvent(SourceGitHub, p.Ref, p.Before, p.After)
event.RepoName = p.Repository.FullName
event.CloneURL = p.Repository.CloneURL
event.HTMLURL = p.Repository.HTMLURL
event.CommitURL = extractGitHubCommitURL(p)
event.Pusher = p.Pusher.Name
return event
}
// extractBranch extracts the branch name from a git ref.
func extractBranch(ref string) string {
// refs/heads/main -> main
const prefix = "refs/heads/"
if len(ref) >= len(prefix) && ref[:len(prefix)] == prefix {
return ref[len(prefix):]
}
return ref
}
// extractGiteaCommitURL extracts the commit URL from a Gitea push payload.
// Prefers the URL from the head commit, falls back to constructing from repo URL.
func extractGiteaCommitURL(payload GiteaPushPayload) UnparsedURL {
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
if payload.Repository.HTMLURL != "" && payload.After != "" {
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
}
return ""
}
// extractGitHubCommitURL extracts the commit URL from a GitHub push payload.
// Prefers head_commit.url, then searches commits, then constructs from repo URL.
func extractGitHubCommitURL(payload GitHubPushPayload) UnparsedURL {
if payload.HeadCommit != nil && payload.HeadCommit.URL != "" {
return payload.HeadCommit.URL
}
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
if payload.Repository.HTMLURL != "" && payload.After != "" {
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
}
return ""
}
// extractGitLabCommitURL extracts the commit URL from a GitLab push payload.
// Prefers commit URL from the commits list, falls back to constructing from
// project web URL.
func extractGitLabCommitURL(payload GitLabPushPayload) UnparsedURL {
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
if payload.Project.WebURL != "" && payload.After != "" {
return UnparsedURL(payload.Project.WebURL.String() + "/-/commit/" + payload.After)
}
return ""
}

View File

@@ -1,93 +0,0 @@
package webhook
import "net/http"
// 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) }
// Source identifies which git hosting platform sent the webhook.
type Source string
const (
// SourceGitea indicates the webhook was sent by a Gitea instance.
SourceGitea Source = "gitea"
// SourceGitHub indicates the webhook was sent by GitHub.
SourceGitHub Source = "github"
// SourceGitLab indicates the webhook was sent by a GitLab instance.
SourceGitLab Source = "gitlab"
// SourceUnknown indicates the webhook source could not be determined.
SourceUnknown Source = "unknown"
)
// String implements the fmt.Stringer interface.
func (s Source) String() string { return string(s) }
// DetectWebhookSource determines the webhook source from HTTP headers.
// It checks for platform-specific event headers in this order:
// Gitea (X-Gitea-Event), GitHub (X-GitHub-Event), GitLab (X-Gitlab-Event).
// Returns SourceUnknown if no recognized header is found.
func DetectWebhookSource(headers http.Header) Source {
if headers.Get("X-Gitea-Event") != "" {
return SourceGitea
}
if headers.Get("X-Github-Event") != "" {
return SourceGitHub
}
if headers.Get("X-Gitlab-Event") != "" {
return SourceGitLab
}
return SourceUnknown
}
// DetectEventType extracts the event type string from HTTP headers
// based on the detected webhook source. Returns "push" as a fallback
// when no event header is found.
func DetectEventType(headers http.Header, source Source) string {
switch source {
case SourceGitea:
if v := headers.Get("X-Gitea-Event"); v != "" {
return v
}
case SourceGitHub:
if v := headers.Get("X-Github-Event"); v != "" {
return v
}
case SourceGitLab:
if v := headers.Get("X-Gitlab-Event"); v != "" {
return v
}
case SourceUnknown:
// Fall through to default
}
return "push"
}
// PushEvent is a normalized representation of a push webhook payload
// from any supported source (Gitea, GitHub, GitLab). The webhook
// service converts source-specific payloads into this format before
// processing.
type PushEvent struct {
Source Source
Ref string
Before string
After string
Branch string
RepoName string
CloneURL UnparsedURL
HTMLURL UnparsedURL
CommitURL UnparsedURL
Pusher string
}

View File

@@ -4,16 +4,16 @@ package webhook
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/deploy"
) )
// ServiceParams contains dependencies for Service. // ServiceParams contains dependencies for Service.
@@ -43,46 +43,68 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
}, nil }, nil
} }
// HandleWebhook processes a webhook request from any supported source // GiteaPushPayload represents a Gitea push webhook payload.
// (Gitea, GitHub, or GitLab). The source parameter determines which //
// payload format to use for parsing. //nolint:tagliatelle // Field names match Gitea API (snake_case)
type GiteaPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL string `json:"compare_url"`
Repository struct {
FullName string `json:"full_name"`
CloneURL string `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL string `json:"html_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
Email string `json:"email"`
} `json:"pusher"`
Commits []struct {
ID string `json:"id"`
URL string `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// HandleWebhook processes a webhook request.
func (svc *Service) HandleWebhook( func (svc *Service) HandleWebhook(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
source Source,
eventType string, eventType string,
payload []byte, payload []byte,
) error { ) error {
svc.log.Info("processing webhook", svc.log.Info("processing webhook", "app", app.Name, "event", eventType)
"app", app.Name,
"source", source.String(),
"event", eventType,
)
// Parse payload into normalized push event // Parse payload
pushEvent, parseErr := ParsePushPayload(source, payload) var pushPayload GiteaPushPayload
if parseErr != nil {
svc.log.Warn("failed to parse webhook payload", unmarshalErr := json.Unmarshal(payload, &pushPayload)
"error", parseErr, if unmarshalErr != nil {
"source", source.String(), svc.log.Warn("failed to parse webhook payload", "error", unmarshalErr)
) // Continue anyway to log the event
// Continue with empty push event to still log the webhook
pushEvent = &PushEvent{Source: source}
} }
// Extract branch from ref
branch := extractBranch(pushPayload.Ref)
commitSHA := pushPayload.After
commitURL := extractCommitURL(pushPayload)
// Check if branch matches // Check if branch matches
matched := pushEvent.Branch == app.Branch matched := branch == app.Branch
// Create webhook event record // Create webhook event record
event := models.NewWebhookEvent(svc.db) event := models.NewWebhookEvent(svc.db)
event.AppID = app.ID event.AppID = app.ID
event.EventType = eventType event.EventType = eventType
event.Branch = pushEvent.Branch event.Branch = branch
event.CommitSHA = sql.NullString{String: pushEvent.After, Valid: pushEvent.After != ""} event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""}
event.CommitURL = sql.NullString{ event.CommitURL = sql.NullString{String: commitURL, Valid: commitURL != ""}
String: pushEvent.CommitURL.String(),
Valid: pushEvent.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
@@ -94,10 +116,9 @@ func (svc *Service) HandleWebhook(
svc.log.Info("webhook event recorded", svc.log.Info("webhook event recorded",
"app", app.Name, "app", app.Name,
"source", source.String(), "branch", branch,
"branch", pushEvent.Branch,
"matched", matched, "matched", matched,
"commit", pushEvent.After, "commit", commitSHA,
) )
// If branch matches, trigger deployment // If branch matches, trigger deployment
@@ -132,3 +153,33 @@ func (svc *Service) triggerDeployment(
_ = event.Save(deployCtx) _ = event.Save(deployCtx)
}() }()
} }
// extractBranch extracts the branch name from a git ref.
func extractBranch(ref string) string {
// refs/heads/main -> main
const prefix = "refs/heads/"
if len(ref) >= len(prefix) && ref[:len(prefix)] == prefix {
return ref[len(prefix):]
}
return ref
}
// extractCommitURL extracts the commit URL from the webhook payload.
// Prefers the URL from the head commit, falls back to constructing from repo URL.
func extractCommitURL(payload GiteaPushPayload) string {
// Try to find the URL from the head commit (matching After SHA)
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
// Fall back to constructing URL from repo HTML URL
if payload.Repository.HTMLURL != "" && payload.After != "" {
return payload.Repository.HTMLURL + "/commit/" + payload.After
}
return ""
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -4,9 +4,9 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/upaas/internal/ssh"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/ssh"
) )
func TestGenerateKeyPair(t *testing.T) { func TestGenerateKeyPair(t *testing.T) {

View File

@@ -1,121 +0,0 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes NOTHING is present (not git,
# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt
# it is installed from a hash-verified GitHub release archive (never
# curl | sh).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-08-07. Never "latest"; exact versions only.
GOLANGCI_LINT_VERSION="2.12.2"
# sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
PKGMGR=""
SUDO=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# apt has no golangci-lint package: install a pinned release archive
# from GitHub, verified by hardcoded sha256 (never curl | sh).
install_golangci_lint_release() {
case "$(uname -m)" in
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;;
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;;
*)
echo "bootstrap: unsupported architecture $(uname -m)" >&2
exit 1
;;
esac
if missing curl; then pkg_install curl curl curl curl; fi
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}"
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/$name.tar.gz" \
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz"
verify_sha256 "$tmp/$name.tar.gz" "$sha"
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
$SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint
rm -rf "$tmp"
}
ensure_golangci_lint() {
if ! missing golangci-lint; then return 0; fi
detect_pkgmgr
case "$PKGMGR" in
apt) install_golangci_lint_release ;;
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;;
esac
}
main() {
cd "$ROOT"
# Base tooling
if missing git; then pkg_install git git git git; fi
if missing make; then pkg_install gnumake make make make; fi
# Go toolchain and linter
if missing go; then pkg_install go golang go go; fi
ensure_golangci_lint
go mod download
echo "bootstrap complete"
}
main "$@"

View File

@@ -1,15 +0,0 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
# Generic: usually needs no adaptation.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

View File

@@ -1,15 +0,0 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs the checks
# (make fmt-check, lint, test), so a successful build implies a green
# repo. Generic: needs no adaptation. The Gitea workflow runs this on
# push.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build .
}
main "$@"

View File

@@ -1,15 +0,0 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
# Generic: needs no adaptation.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"

View File

@@ -1,14 +0,0 @@
#!/bin/sh
# script/fmt: format all files (writes).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
gofmt -s -w .
goimports -w .
npx prettier --write --tab-width 4 static/js/*.js
}
main "$@"

View File

@@ -1,17 +0,0 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Same scope as
# script/fmt, but fails instead of writing.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
if [ -n "$(gofmt -l .)" ]; then
echo "Files not formatted:"
gofmt -l .
exit 1
fi
}
main "$@"

View File

@@ -1,16 +0,0 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
# Generic: needs no adaptation.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# script/lint: run the linter.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
golangci-lint run --config .golangci.yml ./...
}
main "$@"

View File

@@ -1,21 +0,0 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all. Go repo
# extras: go mod tidy must not change go.mod/go.sum.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
go mod tidy
git diff --exit-code -- go.mod go.sum || {
echo "precommit: go mod tidy changed go.mod/go.sum;" \
"stage the changes and retry" >&2
exit 1
}
"$SCRIPT_DIR/check"
}
main "$@"

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "upaas"
}
main "$@"

View File

@@ -1,14 +0,0 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies (script/bootstrap) and the git pre-commit hook.
# Add any repo-specific initialization (db init, .env template) here.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# script/test: run the test suite.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
go test -v -race -cover -timeout 30s ./...
}
main "$@"

3047
static/js/alpine.min.js vendored

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More