12 Commits
Author SHA1 Message Date
clawbot 19619b1cd2 Wait for webhook deployments in handlers tests instead of sleeping (closes #211)
Check / check (pull_request) Successful in 3m24s
`TestHandleWebhookProcessesValidWebhook` waited for the background deployment with a 100 ms sleep, so its temp directory could be removed while the deployment was still writing its log there, and the test failed now and then. It now waits with the webhook service's `WaitForDeployments`, the fix already applied to the webhook service tests for #198. The test context keeps the webhook service it already built. No other handlers test can start a deployment. No production code change.

Model: opus-5-5
2026-09-23 03:04:31 +02:00
clawbot 567f982c3b Re-vendor canonical .golangci.yml from prompts (closes #208)
Check / check (pull_request) Successful in 3m35s
Replaces the local `.golangci.yml` with the canonical file from `sneak/prompts`, fetched unchanged. This turns off the deprecated `gomodguard` (removing its warning from every lint run), turns on `depguard`, and gives the already-running `gomodguard_v2` its module block list. The new config finds nothing in upaas, so no source changes. The `test-support` deny list stays canonical because upaas has no separate test-support packages.

Model: opus-5-5
2026-09-23 02:34:27 +02:00
clawbot 1d769834de Vendor pinned prettier/format toolchain from prompts scaffold (closes #203)
Check / check (pull_request) Successful in 3m15s
script/fmt ran unpinned npx prettier with an inline --tab-width and no config, so formatting was not reproducible. This vendors the canonical format toolchain from the prompts scaffold: .prettierrc (tabWidth 4, proseWrap always), package.json and yarn.lock pinning prettier 3.8.1, and a script/bootstrap that installs node/yarn from a hash-verified nvm archive. script/fmt now reads the config over static/js and markdown; gofmt/goimports unchanged. .prettierignore keeps the *.min.js rule protecting the vendored alpine.min.js. Existing markdown was reflowed to house style.

Disclosure: make check does not gate prettier; the linter's pre-existing gomodguard deprecation warning is unrelated and left as-is.

Model: opus-4-8 (implementation and review)
2026-09-22 18:43:58 +00:00
sneak 6026e3923d 1.1.0 milestone: security, lint, and bootstrap fixes (#199)
Check / check (push) Successful in 5s
Reviewed-on: #199
2026-09-22 20:39:11 +02:00
clawbot f2e4be5eed Fix flaky t.TempDir cleanup race in webhook tests (closes #198)
Check / check (pull_request) Successful in 3m33s
HandleWebhook starts a deployment in a detached goroutine that writes
under the app data directory, which is the tests t.TempDir; the tests
slept 100ms and returned, racing Go automatic TempDir cleanup and
intermittently failing with RemoveAll: directory not empty. The webhook
Service now tracks those goroutines in a sync.WaitGroup and exposes
WaitForDeployments; the tests wait on it instead of sleeping. Production
behavior is unchanged apart from making completion observable.

Model: opus-4-8
2026-09-22 12:28:23 +02:00
clawbot d946fa68f9 Run all linting in Docker via Dockerfile.lint (closes #188)
Check / check (pull_request) Successful in 1m29s
Per the owner ruling, linting now runs only inside Docker with the
pinned golangci-lint (v2.12.2). A root Dockerfile.lint runs the linter as
a build step; script/lint just builds it. A GATE_RUN build arg forces the
lint layer to execute every run so a cached build cannot report a false
clean. script/bootstrap no longer installs golangci-lint (the goimports
install stays). The main Dockerfile lint stage calls golangci-lint
directly (no docker-in-docker) and still gates the build. config verify is
omitted because it fetches its schema over an unpinned HTTPS call.

Model: opus-4-8
2026-09-22 12:11:19 +02:00
clawbot 727bd50935 Install pinned goimports in script/bootstrap (closes #184)
Check / check (pull_request) Successful in 1m49s
script/fmt runs goimports, but script/bootstrap did not install it, so
make fmt failed with goimports: not found on a fresh machine. bootstrap
now installs goimports v0.49.0 (pinned; compatible with the repo Go 1.25,
so no toolchain download) into /usr/local/bin, guarded to skip when it is
already present. Node/prettier pinning is left to a separate issue; the
check gate runs only gofmt, so main is unaffected.

Model: opus-4-8
2026-09-22 11:11:15 +02:00
clawbot f1dfd382a4 Reject path traversal in deploy log download handler (closes #177)
The deploy-log download handler passed a request-derived path to
http.ServeFile, which gosec flags as G703 (path traversal via taint).
The handler now opens the log through an os.Root confined to the deploy
log directory, so any escaping path is rejected at runtime (404) and the
file is streamed with http.ServeContent. A regression test plants a
sentinel outside the log dir and asserts the traversal is refused and its
contents never served; removing the guard makes that test fail. No
//nolint used.

Model: opus-4-8
2026-09-22 11:01:07 +02:00
clawbot 1d38585431 Add .prettierignore for vendored minified JS (closes #185)
script/fmt ran prettier over static/js/*.js, which rewrote the vendored
minified static/js/alpine.min.js. A root .prettierignore with *.min.js
excludes vendored bundles: make fmt on a clean tree now yields no changes
and alpine.min.js stays byte-identical, while first-party JS still formats.

Model: opus-4-8
2026-09-22 10:00:50 +02:00
sneak 8597b70954 Fix four deployability blockers found by QA (#193)
Check / check (push) Failing after 13s
Reviewed-on: #193
2026-09-10 11:17:40 +02:00
sneak cdcf527b25 Fix four deployability blockers found by QA (closes #189, closes #190, closes #191, closes #192)
Check / check (pull_request) Failing after 13s
- CSRF over plain HTTP (#189): gorilla/csrf assumed https for its
  same-origin check, so setup and every POST returned 403 over plain
  HTTP. Gate csrf.PlaintextHTTPRequest on a new UPAAS_PLAINTEXT_HTTP
  config value; the default keeps https, correct for a TLS-terminating
  reverse proxy. The README plain-HTTP recipe now sets it.
- git image never pulled (#190): ensureImage pulls alpine/git (pinned
  digest unchanged) when absent, before the clone container is created.
- port-mapping 500 (#192): the ports delete form used {{ .CSRFField }}
  inside {{range .Ports}}, where the dot is a *models.Port; use
  {{ $.CSRFField }} like the labels and volumes blocks.
- env-var 403 (#191): the editor read the CSRF token from $el (the
  submitting form, which has none) instead of $root, sending an empty
  token; read from $root.

Model: opus-4-8
2026-09-09 14:12:09 +00:00
clawbotandsneak 7a34fc999c Update golangci-lint to v2.12.2 with canonical config (#187)
Check / check (push) Successful in 4s
Bumps golangci-lint from v2.10.1 to v2.12.2 everywhere it is pinned and installs the canonical `.golangci.yml`, then fixes every finding the new linter surfaces so `make check` is green.

## Version pins

- `Dockerfile` lint stage: `golangci/golangci-lint:v2.12.2` (Debian-based), tag plus digest pin
- `script/bootstrap`: `GOLANGCI_LINT_VERSION=2.12.2` with updated `linux-amd64`/`linux-arm64` release-archive sha256 pins

## Config

`.golangci.yml` replaced with the canonical config. Material change: the old file declared `version: "2"` but kept settings under the legacy top-level `linters-settings` key, which golangci-lint v2 ignores — so the intended thresholds (`lll` 88, `funlen` 80/50, `cyclop` 15, `dupl` 100) were not being applied. The canonical file moves them under `linters.settings` and drops `issues.exclude-use-default`.

## Lint fixes (216 findings)

- `lll` (96): wrapped lines to the 88-column limit
- `noctx` (46): `httptest.NewRequestWithContext` with `t.Context()` throughout the tests
- `goconst` (24): shared constants for template/JSON keys in `internal/handlers` and repeated test literals
- `gosec` (23): app-page redirects now go through a `redirectToApp` helper that path-escapes the app ID (G710 open redirect); `http.ServeFile` of the internally derived deployment log path annotated like the adjacent `os.Stat` (G703)
- `dupl` (22): extracted a generic `findAllByAppID` in `internal/models`, a `deleteAppResource` helper in `internal/handlers`, a shared `parsePush` in `internal/service/webhook`, and table-driven/helper-based dedup in tests
- `nolintlint` (5): removed `//nolint:funlen` directives made obsolete by the new limits (plus one more that became obsolete after refactoring)
- `nilerr` (3, surfaced during fixing): resource-delete lookups now propagate the find error to the caller

No behavior changes intended; all tests pass and `make check` is green.

Note: golangci-lint v2.12 warns that `gomodguard` is deprecated in favor of `gomodguard_v2` — a future canonical-config update should address this centrally.
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #187
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 22:21:42 +02:00
54 changed files with 1788 additions and 925 deletions
+71 -5
View File
@@ -1,21 +1,30 @@
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
linters: linters:
default: all default: all
enable:
# Successor to the deprecated gomodguard. Named explicitly, rather than
# left to `default: all`, because it carries the module policy below.
- gomodguard_v2
disable: disable:
# Genuinely incompatible with project patterns # Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields - exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- 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
# Deprecated: the warning is attached to the old name, so it is
linters-settings: # silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
settings:
lll: lll:
line-length: 88 line-length: 88
funlen: funlen:
@@ -25,8 +34,65 @@ linters-settings:
max-complexity: 15 max-complexity: 15
dupl: dupl:
threshold: 100 threshold: 100
depguard:
# Test-support code must not be compiled into the shipped binary. A
# test-support package exists to hand a test privileges the program
# itself must never have, so a file that is not a test must not import
# one. Test files, and the files inside a package whose directory name
# ends in `test`, are where that code belongs, and are exempt.
#
# The deny list below is the one part of this file a repository is
# expected to extend, and the only part it may. depguard matches an
# import path against a list of prefixes, so it cannot be told "any path
# whose last segment ends in test"; a repository's own test-support
# packages have to be named here one at a time, by full import path,
# under a module path that differs from repository to repository. Add
# them; change nothing else.
rules:
test-support:
list-mode: lax
files:
- "$all"
- "!$test"
- "!**/*test/**"
deny:
- pkg: net/http/httptest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
# Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly.
gomodguard_v2:
blocked:
- module: github.com/rs/zerolog
recommendations:
- log/slog
reason: "Structured logging is stdlib log/slog."
# One entry per pre-fork module path, because the later releases
# are separate paths. A prefix match would be shorter but would
# also reach github.com/go-redis/redismock, the test double for
# the successor these entries recommend.
- module: github.com/go-redis/redis
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v7
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v8
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/sergi/go-diff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "No unified diff output; use go-udiff."
- module: github.com/hexops/gotextdiff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "Unmaintained fork; use go-udiff."
issues: issues:
exclude-use-default: false
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0
+5
View File
@@ -0,0 +1,5 @@
node_modules/
yarn.lock
# Vendored, minified third-party bundles must never be reformatted.
*.min.js
+4
View File
@@ -0,0 +1,4 @@
{
"tabWidth": 4,
"proseWrap": "always"
}
+18 -12
View File
@@ -1,6 +1,8 @@
# Go HTTP Server Conventions # Go HTTP Server Conventions
This document defines the architectural patterns, design decisions, and conventions for building Go HTTP servers. All new projects must follow these standards. This document defines the architectural patterns, design decisions, and
conventions for building Go HTTP servers. All new projects must follow these
standards.
## Table of Contents ## Table of Contents
@@ -26,7 +28,7 @@ This document defines the architectural patterns, design decisions, and conventi
These libraries are **mandatory** for all new projects: These libraries are **mandatory** for all new projects:
| Purpose | Library | Import Path | | Purpose | Library | Import Path |
|---------|---------|-------------| | -------------------- | --------------- | ------------------------------------- |
| Dependency Injection | Uber fx | `go.uber.org/fx` | | Dependency Injection | Uber fx | `go.uber.org/fx` |
| HTTP Router | go-chi | `github.com/go-chi/chi` | | HTTP Router | go-chi | `github.com/go-chi/chi` |
| Logging | slog (stdlib) | `log/slog` | | Logging | slog (stdlib) | `log/slog` |
@@ -85,7 +87,8 @@ project-root/
### Key Principles ### Key Principles
- **`cmd/{appname}/`**: Only the entry point. Minimal logic, just bootstrapping. - **`cmd/{appname}/`**: Only the entry point. Minimal logic, just bootstrapping.
- **`internal/`**: All application packages. Not importable by external projects. - **`internal/`**: All application packages. Not importable by external
projects.
- **One package per concern**: config, database, handlers, middleware, etc. - **One package per concern**: config, database, handlers, middleware, etc.
- **Flat handler files**: One file per handler or logical group of handlers. - **Flat handler files**: One file per handler or logical group of handlers.
@@ -190,7 +193,8 @@ Providers are resolved automatically by fx, but conceptually follow this order:
2. `logger.New` - Logger (depends on Globals) 2. `logger.New` - Logger (depends on Globals)
3. `config.New` - Configuration (depends on Globals, Logger) 3. `config.New` - Configuration (depends on Globals, Logger)
4. `database.New` - Database (depends on Logger, Config) 4. `database.New` - Database (depends on Logger, Config)
5. `healthcheck.New` - Health check (depends on Globals, Config, Logger, Database) 5. `healthcheck.New` - Health check (depends on Globals, Config, Logger,
Database)
6. `middleware.New` - Middleware (depends on Logger, Globals, Config) 6. `middleware.New` - Middleware (depends on Logger, Globals, Config)
7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck) 7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck)
8. `server.New` - Server (depends on all above) 8. `server.New` - Server (depends on all above)
@@ -453,7 +457,8 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
### Closure-Based Handler Pattern ### Closure-Based Handler Pattern
All handlers return `http.HandlerFunc` using the closure pattern. This allows initialization logic to run once when the handler is created: All handlers return `http.HandlerFunc` using the closure pattern. This allows
initialization logic to run once when the handler is created:
```go ```go
// internal/handlers/index.go // internal/handlers/index.go
@@ -510,7 +515,8 @@ func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interfac
### Handler Naming Convention ### Handler Naming Convention
- `HandleIndex()` - Main page - `HandleIndex()` - Main page
- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method suffix - `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method
suffix
- `HandleNow()` - API endpoints - `HandleNow()` - API endpoints
- `HandleHealthCheck()` - System endpoints - `HandleHealthCheck()` - System endpoints
@@ -733,7 +739,8 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
1. **Environment variables** (highest priority via `AutomaticEnv()`) 1. **Environment variables** (highest priority via `AutomaticEnv()`)
2. **`.env` file** (loaded via `godotenv/autoload` import) 2. **`.env` file** (loaded via `godotenv/autoload` import)
3. **Config files**: `/etc/{appname}/{appname}.yaml`, `~/.config/{appname}/{appname}.yaml` 3. **Config files**: `/etc/{appname}/{appname}.yaml`,
`~/.config/{appname}/{appname}.yaml`
4. **Defaults** (lowest priority) 4. **Defaults** (lowest priority)
### Environment Loading ### Environment Loading
@@ -1005,6 +1012,7 @@ var Static embed.FS
``` ```
Directory structure: Directory structure:
``` ```
static/ static/
├── static.go ├── static.go
@@ -1045,15 +1053,13 @@ Templates use Go's template composition:
```html ```html
<!-- index.html --> <!-- index.html -->
{{ template "htmlheader.html" . }} {{ template "htmlheader.html" . }} {{ template "navbar.html" . }}
{{ template "navbar.html" . }}
<main> <main>
<!-- Page content --> <!-- Page content -->
</main> </main>
{{ template "pagefooter.html" . }} {{ template "pagefooter.html" . }} {{ template "htmlfooter.html" . }}
{{ template "htmlfooter.html" . }}
``` ```
### Static Asset References ### Static Asset References
@@ -1215,7 +1221,7 @@ if viper.GetString("METRICS_USERNAME") != "" {
### Environment Variables Summary ### Environment Variables Summary
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| | ------------------ | -------------------------------- | ------- |
| `PORT` | HTTP listen port | 8080 | | `PORT` | HTTP listen port | 8080 |
| `DEBUG` | Enable debug logging | false | | `DEBUG` | Enable debug logging | false |
| `DBURL` | Database connection URL | "" | | `DBURL` | Database connection URL | "" |
+7 -3
View File
@@ -1,6 +1,6 @@
# Lint stage — fast feedback on formatting and lint issues # Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.10.1 # golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint@sha256:ea84d14c2fef724411be7dc45e09e6ef721d748315252b02df19a7e3113ee763 AS lint FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -8,8 +8,12 @@ RUN go mod download
COPY . . COPY . .
# golangci-lint is invoked directly here, not via `make lint`: script/lint
# now runs the linter by building Dockerfile.lint, and shelling out to
# `docker build` from inside this image build would be docker-in-docker.
# This image is golangci/golangci-lint, so the pinned linter is on PATH.
RUN make fmt-check RUN make fmt-check
RUN make lint RUN golangci-lint run --config .golangci.yml ./...
# Build stage — tests and compilation # Build stage — tests and compilation
# golang:1.25-alpine # golang:1.25-alpine
+23
View File
@@ -0,0 +1,23 @@
# Lint image — runs golangci-lint inside a container so every lint uses
# the pinned linter, never a host binary. Linting is a build step, so a
# successful build is a clean lint. Built by script/lint.
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Caching is waived for linting: on an unchanged tree a cached build runs
# no linter and still exits 0 in under a second. script/lint passes a
# fresh GATE_RUN every time, and referencing it here forces this step to
# re-run, so the linter always executes.
#
# `golangci-lint config verify` is deliberately NOT run: it fetches its
# JSON schema over an unpinned live HTTPS call, which REPO_POLICIES.md
# forbids (all external references must be pinned by hash).
ARG GATE_RUN
RUN echo "lint run: ${GATE_RUN}"; golangci-lint run --config .golangci.yml ./...
+37 -20
View File
@@ -1,12 +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 webhooks from Gitea, GitHub, or GitLab.
## 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 with auto-detection of Gitea, GitHub, and
GitLab
- 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 - CPU and memory resource limits per app
@@ -95,9 +97,12 @@ chi Router ──► Middleware Stack ──► Handler
### Key Patterns ### Key Patterns
- **Closure-based handlers**: Handlers return `http.HandlerFunc` allowing one-time initialization - **Closure-based handlers**: Handlers return `http.HandlerFunc` allowing
- **Active Record models**: Models encapsulate database operations (`Save()`, `Delete()`, `Reload()`) one-time initialization
- **Async deployments**: Webhook triggers deploy via goroutine with `context.WithoutCancel()` - **Active Record models**: Models encapsulate database operations (`Save()`,
`Delete()`, `Reload()`)
- **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 ## Entrypoints
@@ -105,12 +110,12 @@ chi Router ──► Middleware Stack ──► Handler
This repository adheres to the This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call development workflow, and the Makefile targets are thin shims that call them. We
them. We provide: provide:
- `script/bootstrap` — install all dependencies (idempotent) - `script/bootstrap` — install all dependencies (idempotent)
- `script/setup` — make a fresh clone ready for development - `script/setup` — make a fresh clone ready for development (bootstrap, then
(bootstrap, then install-precommit) install-precommit)
- `script/projectname` — output the project name ("upaas") - `script/projectname` — output the project name ("upaas")
- `script/test` — run the test suite - `script/test` — run the test suite
- `script/lint` — run golangci-lint - `script/lint` — run golangci-lint
@@ -118,12 +123,12 @@ them. We provide:
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check - `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname` - `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile - `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile runs the
runs the checks, so a green build implies a green repo) checks, so a green build implies a green repo)
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then - `script/precommit` — pre-commit checks (`go mod tidy` guard, then
`script/check`) `script/check`)
- `script/install-precommit` — install the git pre-commit hook that - `script/install-precommit` — install the git pre-commit hook that runs
runs `script/precommit` `script/precommit`
## Development ## Development
@@ -174,6 +179,7 @@ git commit -m "Your message"
``` ```
The Docker build runs `make check` and will fail if: The Docker build runs `make check` and will fail if:
- Code is not formatted - Code is not formatted
- Linting errors exist - Linting errors exist
- Tests fail - Tests fail
@@ -186,11 +192,12 @@ This ensures the main branch always contains clean, tested, working code.
Environment variables: 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` (local dev only — use absolute path for Docker) |
| `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) | _(none — must be set to an absolute path)_ |
| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock | | `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock |
| `UPAAS_PLAINTEXT_HTTP` | Set when µPaaS is reached over plain HTTP (no TLS-terminating proxy in front) so CSRF origin checks use `http://`. Leave unset behind a TLS-terminating reverse proxy. | false |
| `DEBUG` | Enable debug logging | false | | `DEBUG` | Enable debug logging | false |
| `SENTRY_DSN` | Sentry error reporting DSN | "" | | `SENTRY_DSN` | Sentry error reporting DSN | "" |
| `METRICS_USERNAME` | Basic auth for /metrics | "" | | `METRICS_USERNAME` | Basic auth for /metrics | "" |
@@ -204,9 +211,14 @@ docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
-v /path/on/host/upaas-data:/var/lib/upaas \ -v /path/on/host/upaas-data:/var/lib/upaas \
-e UPAAS_HOST_DATA_DIR=/path/on/host/upaas-data \ -e UPAAS_HOST_DATA_DIR=/path/on/host/upaas-data \
-e UPAAS_PLAINTEXT_HTTP=true \
upaas upaas
``` ```
This recipe serves plain HTTP, so `UPAAS_PLAINTEXT_HTTP=true` is required for
setup and every other form to pass the CSRF origin check. Behind a
TLS-terminating reverse proxy, drop that line.
### Docker Compose ### Docker Compose
```yaml ```yaml
@@ -221,6 +233,8 @@ services:
- ${HOST_DATA_DIR}:/var/lib/upaas - ${HOST_DATA_DIR}:/var/lib/upaas
environment: environment:
- UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR} - UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR}
# Set when serving plain HTTP (no TLS-terminating proxy); drop behind one
- UPAAS_PLAINTEXT_HTTP=true
# Optional: uncomment to enable debug logging # Optional: uncomment to enable debug logging
# - DEBUG=true # - DEBUG=true
# Optional: Sentry error reporting # Optional: Sentry error reporting
@@ -230,14 +244,17 @@ services:
# - METRICS_PASSWORD=secret # - METRICS_PASSWORD=secret
``` ```
**Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the host before running **Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the
`docker compose up`. This value is bind-mounted into the container and passed as `UPAAS_HOST_DATA_DIR` host before running `docker compose up`. This value is bind-mounted into the
so that Docker bind mounts during builds resolve correctly. Relative paths (e.g. `./data`) will break container and passed as `UPAAS_HOST_DATA_DIR` so that Docker bind mounts during
container builds because the Docker daemon resolves paths relative to the host, not the container. 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` 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`.
## License ## License
+61 -46
View File
@@ -1,65 +1,80 @@
# Workflow # Workflow
* branch (from `main`) - branch (from `main`)
* do the work in Next Step - do the work in Next Step
* move Next Step to the top of Completed Steps - move Next Step to the top of Completed Steps
* move the top item of Future Steps into Next Step - move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work) - commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR - merge to `main` if the branch is not protected, otherwise open a PR
* push - push
# Status # Status
1.0+. Tagged 1.0.0 on 2026-02-26. Policy violation: main currently 1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. `make check` is green
fails make check under golangci-lint >= 2.12 (25 lint issues remaining: as of the golangci-lint v2.12.2 update.
1 gosec G703, 24 goconst), so the tree is out of compliance until
fixed. CI (Dockerfile lint stage, pinned golangci-lint v2.10.1) is
green; the pin bump is tracked in issue #179. The road to release
1.1.0 is tracked in Gitea issues #175-#185 (milestone 1.1.0).
# Next Step # Next Step
Fix the gosec G703 path traversal finding in the deploy log download Confirm `.gitea/workflows/check.yml` gates merges on `make check` so main cannot
handler (issue #177): canonicalize and containment-check the log path regress.
before http.ServeFile, with a traversal-rejection test.
# Completed Steps # Completed Steps
- 2026-08-07: Fixed all 22 gosec G710 open-redirect findings: app - 2026-09-23: Fixed the flaky `t.TempDir` cleanup race in `internal/handlers`
redirects go through a redirectToApp helper that ULID-validates the (the one fixed in `internal/service/webhook` by #198):
app ID (#176). `TestHandleWebhookProcessesValidWebhook` now waits with the webhook service's
- 2026-08-07: Fixed all 47 noctx lint findings: tests now use `WaitForDeployments` instead of sleeping (#211).
httptest.NewRequestWithContext with t.Context() (#175). - 2026-09-22: Vendored the canonical prettier/format toolchain from the
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, `sneak/prompts` scaffold: added `.prettierrc` (tabWidth 4, proseWrap always),
Makefile shims, README Entrypoints section pinned `package.json` + `yarn.lock` (prettier 3.8.1), taught
`script/bootstrap` to install a pinned node/yarn via a hash-verified nvm
archive, and switched `script/fmt` to the pinned prettier reading
`.prettierrc` (no inline flags) over `static/js/*.js` and `**/*.md`. Reflowed
all markdown to house style; `alpine.min.js` stays byte-identical (#203).
- 2026-09-22: Fixed the flaky `t.TempDir` cleanup race in
`internal/service/webhook` by tracking the async deployment goroutine in a
`sync.WaitGroup` and exposing `WaitForDeployments`; tests now synchronize on
completion instead of sleeping (#198).
- 2026-09-22: Linting now runs only in Docker. Added `Dockerfile.lint` (pinned
golangci-lint v2.12.2, cache-busted via a `GATE_RUN` build arg so the linter
always executes), reduced `script/lint` to building it, dropped the
golangci-lint install from `script/bootstrap`, and switched the `Dockerfile`
lint stage to invoke `golangci-lint` directly instead of `make lint` to avoid
docker-in-docker (#188).
- 2026-09-22: Added `.prettierignore` so `make fmt` no longer rewrites the
vendored `static/js/alpine.min.js` bundle (#185).
- 2026-09-22: Fixed the gosec G703 path-traversal finding in the deploy log
download handler by verifying the resolved path stays within the deploy log
directory before serving, returning 404 on escape (#177).
- 2026-09-22: `script/bootstrap` now installs a pinned `goimports`
(`golang.org/x/tools` v0.49.0) into `/usr/local/bin`, so `make fmt` succeeds
on a fresh machine after `make bootstrap` (#184).
- 2026-09-09: Fixed four deployability blockers found by QA: CSRF origin check
over plain HTTP (`UPAAS_PLAINTEXT_HTTP`, #189), pulling the git image when
absent (#190), the env-var editor CSRF token lookup (#191), and the
port-mapping delete form's CSRF field (#192).
- 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-11: Monolithic env var editing with bulk save (#158).
- 2026-03-10: Webhook event history UI page (#164); added missing - 2026-03-10: Webhook event history UI page (#164); added missing Makefile
Makefile docker and hooks targets plus test timeout (#159); docker and hooks targets plus test timeout (#159); notification settings
notification settings passed from create form (#160). passed from create form (#160).
- 2026-03-03: REPO_POLICIES compliance file set added (#155). - 2026-03-03: REPO_POLICIES compliance file set added (#155).
- 2026-03-01: Module path changed to sneak.berlin/go/upaas (#143); - 2026-03-01: Module path changed to sneak.berlin/go/upaas (#143); Dockerfile
Dockerfile split into lint and build stages with forced lint split into lint and build stages with forced lint execution (#152, #154).
execution (#152, #154).
- 2026-02-26: 1.0.0 tagged; dashboard CSRFField crash fixed (#146). - 2026-02-26: 1.0.0 tagged; dashboard CSRFField crash fixed (#146).
- 1.0 audit bug fixes (#120-#125): deferred rollback on commit error, - 1.0 audit bug fixes (#120-#125): deferred rollback on commit error, deployment
deployment log size cap, error path rendering, docker-compose bind log size cap, error path rendering, docker-compose bind mount, domain type
mount, domain type refactor. refactor.
- CI simplified to docker build only (#130). - CI simplified to docker build only (#130).
- 2025-12-29 onward: core PaaS built out: deploys with real-time build - 2025-12-29 onward: core PaaS built out: deploys with real-time build log
log streaming, container start/stop/restart and logs, TCP/UDP port streaming, container start/stop/restart and logs, TCP/UDP port mapping,
mapping, Alpine.js UI, Slack notifications, ULID app IDs, session Alpine.js UI, Slack notifications, ULID app IDs, session handling.
handling.
# Future Steps # Future Steps
- Get main green (compliance, ordered):
- Fix 1 gosec G703 finding (Next Step, #177).
- Fix 24 goconst findings (#178).
- Bump Dockerfile golangci-lint pin to v2.12.x (#179).
- Run make check clean on main and keep it green; main must always
pass.
- Confirm .gitea/workflows/check.yml gates merges on make check so main
cannot regress (#180).
- Deploy to fsn1app1 and verify end-to-end (#181), then tag 1.1.0
(#182).
- Resume feature work only after main is green. - Resume feature work only after main is green.
+4 -1
View File
@@ -45,10 +45,11 @@ type Config struct {
Port int Port int
Debug bool Debug bool
DataDir string DataDir string
HostDataDir string // Host path for DataDir (for Docker bind mounts when running in container) HostDataDir string // Host path for DataDir (Docker bind mounts in container)
DockerHost string DockerHost string
SentryDSN string SentryDSN string
MaintenanceMode bool MaintenanceMode bool
PlaintextHTTP bool // clients reach µPaaS over plain HTTP (no TLS-terminating proxy)
MetricsUsername string MetricsUsername string
MetricsPassword string MetricsPassword string
SessionSecret string `json:"-"` SessionSecret string `json:"-"`
@@ -100,6 +101,7 @@ func setupViper(name string) {
viper.SetDefault("DOCKER_HOST", "unix:///var/run/docker.sock") viper.SetDefault("DOCKER_HOST", "unix:///var/run/docker.sock")
viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("MAINTENANCE_MODE", false) viper.SetDefault("MAINTENANCE_MODE", false)
viper.SetDefault("PLAINTEXT_HTTP", false)
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("SESSION_SECRET", "") viper.SetDefault("SESSION_SECRET", "")
@@ -135,6 +137,7 @@ func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
DockerHost: viper.GetString("DOCKER_HOST"), DockerHost: viper.GetString("DOCKER_HOST"),
SentryDSN: viper.GetString("SENTRY_DSN"), SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
PlaintextHTTP: viper.GetBool("PLAINTEXT_HTTP"),
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"),
+2 -1
View File
@@ -178,7 +178,8 @@ 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 WHERE webhook_secret_hash = '' AND webhook_secret != ''") "SELECT id, webhook_secret FROM apps"+
" 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)
} }
+14 -3
View File
@@ -32,7 +32,10 @@ var ErrInvalidMigrationFilename = errors.New("invalid migration filename")
func ParseMigrationVersion(filename string) (int, error) { func ParseMigrationVersion(filename string) (int, error) {
name := strings.TrimSuffix(filename, ".sql") name := strings.TrimSuffix(filename, ".sql")
if name == "" || name == filename { if name == "" || name == filename {
return 0, fmt.Errorf("%w: %q has no .sql extension or is empty", ErrInvalidMigrationFilename, filename) return 0, fmt.Errorf(
"%w: %q has no .sql extension or is empty",
ErrInvalidMigrationFilename, filename,
)
} }
// Split on underscore to separate version from description. // Split on underscore to separate version from description.
@@ -40,7 +43,10 @@ func ParseMigrationVersion(filename string) (int, error) {
versionStr, _, _ := strings.Cut(name, "_") versionStr, _, _ := strings.Cut(name, "_")
if versionStr == "" { if versionStr == "" {
return 0, fmt.Errorf("%w: %q has empty version prefix", ErrInvalidMigrationFilename, filename) return 0, fmt.Errorf(
"%w: %q has empty version prefix",
ErrInvalidMigrationFilename, filename,
)
} }
// Validate the version is purely numeric. // Validate the version is purely numeric.
@@ -177,7 +183,12 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
// applyMigrationTx reads and executes a migration file within a transaction, // applyMigrationTx reads and executes a migration file within a transaction,
// recording the version in schema_migrations on success. // recording the version in schema_migrations on success.
func applyMigrationTx(ctx context.Context, db *sql.DB, filename string, version int) error { func applyMigrationTx(
ctx context.Context,
db *sql.DB,
filename string,
version int,
) error {
content, err := migrationsFS.ReadFile("migrations/" + filename) content, err := migrationsFS.ReadFile("migrations/" + filename)
if err != nil { if err != nil {
return fmt.Errorf("failed to read migration %s: %w", filename, err) return fmt.Errorf("failed to read migration %s: %w", filename, err)
+81 -13
View File
@@ -41,7 +41,8 @@ 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:d86f367afb53d022acc4377741e7334bc20add161bb10234272b91b459b4b7d8" const gitImage = "alpine/git@sha256:" +
"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")
@@ -145,7 +146,7 @@ type CreateContainerOptions struct {
Volumes []VolumeMount Volumes []VolumeMount
Ports []PortMapping Ports []PortMapping
Network string Network string
CPULimit float64 // CPU cores (e.g. 0.5 = half a core, 2.0 = two cores). 0 means unlimited. CPULimit float64 // CPU cores (0.5 = half a core). 0 means unlimited.
MemoryLimit int64 // Memory in bytes. 0 means unlimited. MemoryLimit int64 // Memory in bytes. 0 means unlimited.
} }
@@ -303,7 +304,11 @@ func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) err
timeout := stopTimeoutSeconds timeout := stopTimeoutSeconds
err := c.docker.ContainerStop(ctx, containerID.String(), container.StopOptions{Timeout: &timeout}) err := c.docker.ContainerStop(
ctx,
containerID.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)
} }
@@ -323,7 +328,11 @@ func (c *Client) RemoveContainer(
c.log.Info("removing container", "id", containerID, "force", force) c.log.Info("removing container", "id", containerID, "force", force)
err := c.docker.ContainerRemove(ctx, containerID.String(), container.RemoveOptions{Force: force}) err := c.docker.ContainerRemove(
ctx,
containerID.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)
} }
@@ -469,7 +478,8 @@ 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 specific commit. // CloneRepo clones a git repository using SSH and optionally checks out a
// 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.
@@ -584,11 +594,13 @@ 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 (base64 layers can be large). // scannerMaxBufferSize is the max buffer size for build log lines
// (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 stdout and optional log writer. // streamBuildOutput reads Docker build output line by line and writes to
// Docker sends newline-delimited JSON, so reading line by line ensures each log entry is written immediately. // stdout and optional log writer. Docker sends newline-delimited JSON, so
// 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)
@@ -616,7 +628,10 @@ func (c *Client) streamBuildOutput(body io.Reader, logWriter io.Writer) error {
return nil return nil
} }
func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResult, error) { func (c *Client) performClone(
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 {
@@ -642,16 +657,61 @@ func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResu
} }
defer func() { defer func() {
_ = c.docker.ContainerRemove(ctx, gitContainerID.String(), container.RemoveOptions{Force: true}) _ = c.docker.ContainerRemove(
ctx,
gitContainerID.String(),
container.RemoveOptions{Force: true},
)
}() }()
return c.runGitClone(ctx, gitContainerID) return c.runGitClone(ctx, gitContainerID)
} }
// ensureImage pulls ref if it is not already present locally. The pinned
// digest is preserved: a pull of an image already present is a no-op, and a
// missing one is fetched before it is used to create a container.
func (c *Client) ensureImage(ctx context.Context, ref string) error {
_, _, err := c.docker.ImageInspectWithRaw(ctx, ref)
if err == nil {
return nil
}
if !client.IsErrNotFound(err) {
return fmt.Errorf("failed to inspect image %s: %w", ref, err)
}
c.log.Info("pulling image", "image", ref)
reader, err := c.docker.ImagePull(ctx, ref, image.PullOptions{})
if err != nil {
return fmt.Errorf("failed to pull image %s: %w", ref, err)
}
defer func() {
closeErr := reader.Close()
if closeErr != nil {
c.log.Error("failed to close image pull reader", "error", closeErr)
}
}()
// The pull only completes once its response stream is fully drained.
_, err = io.Copy(io.Discard, reader)
if err != nil {
return fmt.Errorf("failed to pull image %s: %w", ref, err)
}
return nil
}
func (c *Client) createGitContainer( func (c *Client) createGitContainer(
ctx context.Context, ctx context.Context,
cfg *cloneConfig, cfg *cloneConfig,
) (ContainerID, error) { ) (ContainerID, error) {
err := c.ensureImage(ctx, gitImage)
if err != nil {
return "", err
}
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.
@@ -680,7 +740,8 @@ 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 (Docker runs on the host, not in our container) // Use host paths for Docker bind mounts
// (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,
@@ -711,13 +772,20 @@ func (c *Client) createGitContainer(
return ContainerID(resp.ID), nil return ContainerID(resp.ID), nil
} }
func (c *Client) runGitClone(ctx context.Context, containerID ContainerID) (*CloneResult, error) { func (c *Client) runGitClone(
ctx context.Context,
containerID ContainerID,
) (*CloneResult, error) {
err := c.docker.ContainerStart(ctx, containerID.String(), container.StartOptions{}) 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(ctx, containerID.String(), container.WaitConditionNotRunning) statusCh, errCh := c.docker.ContainerWait(
ctx,
containerID.String(),
container.WaitConditionNotRunning,
)
select { select {
case err := <-errCh: case err := <-errCh:
+9 -6
View File
@@ -6,11 +6,14 @@ 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{
"main", mainBranch,
"develop", "develop",
"feature/my-feature", "feature/my-feature",
"release-1.0", "release-1.0",
@@ -70,7 +73,7 @@ func TestValidCommitSHARegex(t *testing.T) {
} }
} }
func TestCloneRepoRejectsInjection(t *testing.T) { //nolint:funlen // table-driven test func TestCloneRepoRejectsInjection(t *testing.T) {
t.Parallel() t.Parallel()
c := &Client{ c := &Client{
@@ -100,25 +103,25 @@ func TestCloneRepoRejectsInjection(t *testing.T) { //nolint:funlen // table-driv
}, },
{ {
name: "injection in commitSHA", name: "injection in commitSHA",
branch: "main", branch: mainBranch,
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: "main", branch: mainBranch,
commitSHA: "abc123", commitSHA: "abc123",
wantErr: ErrInvalidCommitSHA, wantErr: ErrInvalidCommitSHA,
}, },
{ {
name: "valid inputs pass validation (hit NotConnected)", name: "valid inputs pass validation (hit NotConnected)",
branch: "main", branch: mainBranch,
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: "main", branch: mainBranch,
wantErr: ErrNotConnected, wantErr: ErrNotConnected,
}, },
} }
+10 -10
View File
@@ -84,7 +84,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
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{"error": "invalid JSON body"}, map[string]string{jsonKeyError: "invalid JSON body"},
http.StatusBadRequest) http.StatusBadRequest)
return return
@@ -95,7 +95,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
if username == "" || credential == "" { if username == "" || credential == "" {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{"error": "username and password are required"}, map[string]string{jsonKeyError: "username and password are required"},
http.StatusBadRequest) http.StatusBadRequest)
return return
@@ -104,7 +104,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
user, authErr := h.auth.Authenticate(request.Context(), username, credential) user, authErr := h.auth.Authenticate(request.Context(), username, credential)
if authErr != nil { if authErr != nil {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{"error": "invalid credentials"}, map[string]string{jsonKeyError: "invalid credentials"},
http.StatusUnauthorized) http.StatusUnauthorized)
return return
@@ -114,7 +114,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{"error": "failed to create session"}, map[string]string{jsonKeyError: "failed to create session"},
http.StatusInternalServerError) http.StatusInternalServerError)
return return
@@ -133,7 +133,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{"error": "failed to list apps"}, map[string]string{jsonKeyError: "failed to list apps"},
http.StatusInternalServerError) http.StatusInternalServerError)
return return
@@ -156,7 +156,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{"error": "internal server error"}, map[string]string{jsonKeyError: "internal server error"},
http.StatusInternalServerError) http.StatusInternalServerError)
return return
@@ -164,7 +164,7 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
if application == nil { if application == nil {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{"error": "app not found"}, map[string]string{jsonKeyError: "app not found"},
http.StatusNotFound) http.StatusNotFound)
return return
@@ -185,7 +185,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{"error": "app not found"}, map[string]string{jsonKeyError: "app not found"},
http.StatusNotFound) http.StatusNotFound)
return return
@@ -205,7 +205,7 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
) )
if deployErr != nil { if deployErr != nil {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{"error": "failed to list deployments"}, map[string]string{jsonKeyError: "failed to list deployments"},
http.StatusInternalServerError) http.StatusInternalServerError)
return return
@@ -231,7 +231,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{"error": "unauthorized"}, map[string]string{jsonKeyError: "unauthorized"},
http.StatusUnauthorized) http.StatusUnauthorized)
return return
+150 -111
View File
@@ -15,8 +15,8 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app" "sneak.berlin/go/upaas/internal/service/app"
"sneak.berlin/go/upaas/templates" "sneak.berlin/go/upaas/templates"
@@ -29,25 +29,21 @@ const (
deploymentsHistoryLimit = 50 deploymentsHistoryLimit = 50
) )
// redirectToApp issues a See Other redirect to the detail page of the // redirectToApp issues a SeeOther redirect to the page for the given
// given app, plus an optional suffix (a sub-path like "/deployments" // app ID, with an optional suffix such as "/deployments" or
// or a query string like "?success=updated"). App IDs are ULIDs: the // "?success=updated". The ID is path-escaped so the target is always
// ID is parsed and re-serialized so the redirect target never // a relative application URL.
// contains unvalidated request input; an invalid ID yields 404.
func redirectToApp( func redirectToApp(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
appID, suffix string, appID, suffix string,
) { ) {
id, parseErr := ulid.ParseStrict(appID) http.Redirect(
if parseErr != nil { writer,
http.NotFound(writer, request) request,
"/apps/"+url.PathEscape(appID)+suffix,
return http.StatusSeeOther,
} )
target := "/apps/" + url.PathEscape(id.String()) + suffix
http.Redirect(writer, request, target, http.StatusSeeOther)
} }
// HandleAppNew returns the new app form handler. // HandleAppNew returns the new app form handler.
@@ -62,7 +58,9 @@ 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) {
@@ -142,7 +140,7 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
return return
} }
redirectToApp(writer, request, createdApp.ID, "") http.Redirect(writer, request, "/apps/"+createdApp.ID, http.StatusSeeOther)
} }
} }
@@ -183,10 +181,14 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
} }
webhookURL := "https://" + request.Host + "/webhook/" + application.WebhookSecret webhookURL := "https://" + request.Host + "/webhook/" + application.WebhookSecret
deployKey := formatDeployKey(application.SSHPublicKey, application.CreatedAt, application.Name) deployKey := formatDeployKey(
application.SSHPublicKey,
application.CreatedAt,
application.Name,
)
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"App": application, dataKeyApp: application,
"EnvVars": envVars, "EnvVars": envVars,
"Labels": labels, "Labels": labels,
"Volumes": volumes, "Volumes": volumes,
@@ -224,7 +226,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
} }
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"App": application, dataKeyApp: application,
}, request) }, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data) h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -232,7 +234,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
} }
// HandleAppUpdate handles app updates. // HandleAppUpdate handles app updates.
func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@@ -257,8 +259,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
nameErr := validateAppName(newName) nameErr := validateAppName(newName)
if nameErr != nil { if nameErr != nil {
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"App": application, dataKeyApp: application,
"Error": "Invalid app name: " + nameErr.Error(), dataKeyError: "Invalid app name: " + nameErr.Error(),
}, request) }, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data) h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -268,8 +270,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
repoURLErr := validateRepoURL(request.FormValue("repo_url")) repoURLErr := validateRepoURL(request.FormValue("repo_url"))
if repoURLErr != nil { if repoURLErr != nil {
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"App": application, dataKeyApp: application,
"Error": "Invalid repository URL: " + repoURLErr.Error(), dataKeyError: "Invalid repository URL: " + repoURLErr.Error(),
}, request) }, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data) h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -287,8 +289,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
limitsErr := applyResourceLimits(application, request) limitsErr := applyResourceLimits(application, request)
if limitsErr != "" { if limitsErr != "" {
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"App": application, dataKeyApp: application,
"Error": limitsErr, dataKeyError: limitsErr,
}, request) }, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data) h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -300,8 +302,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
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{
"App": application, dataKeyApp: application,
"Error": "Failed to update app", dataKeyError: "Failed to update app",
}, request) }, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data) h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -462,7 +464,7 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
) )
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"App": application, dataKeyApp: application,
"Deployments": deployments, "Deployments": deployments,
}, request) }, request)
@@ -535,7 +537,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return return
} }
_, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- logs sanitized, Content-Type is text/plain _, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- output sanitized
} }
} }
@@ -574,8 +576,8 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
} }
response := map[string]any{ response := map[string]any{
"logs": logs, jsonKeyLogs: logs,
"status": deployment.Status, jsonKeyStatus: deployment.Status,
} }
_ = json.NewEncoder(writer).Encode(response) _ = json.NewEncoder(writer).Encode(response)
@@ -609,7 +611,13 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
return return
} }
// Get the log file path from deploy service // The log path is derived from request data (the app is looked
// up by a URL parameter), so open it through an os.Root confined
// to the deploy log directory. Root.Open rejects any path that
// escapes the root, so a traversal attempt fails rather than
// serving an arbitrary file.
logDir := h.deploy.GetLogDir()
logPath := h.deploy.GetLogFilePath(application, deployment) logPath := h.deploy.GetLogFilePath(application, deployment)
if logPath == "" { if logPath == "" {
http.NotFound(writer, request) http.NotFound(writer, request)
@@ -617,28 +625,43 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
return return
} }
// Check if file exists — logPath is constructed internally, not from user input relPath, relErr := filepath.Rel(logDir, logPath)
_, err := os.Stat(logPath) // #nosec G703 -- path from internal GetLogFilePath, not user input if relErr != nil {
if os.IsNotExist(err) {
http.NotFound(writer, request) http.NotFound(writer, request)
return return
} }
if err != nil { root, rootErr := os.OpenRoot(logDir)
h.log.Error("failed to stat log file", "error", err, "path", logPath) if rootErr != nil {
http.NotFound(writer, request)
return
}
defer func() { _ = root.Close() }()
file, openErr := root.Open(relPath)
if openErr != nil {
http.NotFound(writer, request)
return
}
defer func() { _ = file.Close() }()
info, statErr := file.Stat()
if statErr != nil {
h.log.Error("failed to stat log file", "error", statErr, "path", logPath)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError) http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return return
} }
// Extract filename for Content-Disposition header
filename := filepath.Base(logPath) filename := filepath.Base(logPath)
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) http.ServeContent(writer, request, filename, info.ModTime(), file)
} }
} }
@@ -662,8 +685,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{
"logs": "No container running\n", jsonKeyLogs: "No container running\n",
"status": "stopped", jsonKeyStatus: "stopped",
} }
_ = json.NewEncoder(writer).Encode(response) _ = json.NewEncoder(writer).Encode(response)
@@ -683,8 +706,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
) )
response := map[string]any{ response := map[string]any{
"logs": "Failed to fetch container logs\n", jsonKeyLogs: "Failed to fetch container logs\n",
"status": "error", jsonKeyStatus: "error",
} }
_ = json.NewEncoder(writer).Encode(response) _ = json.NewEncoder(writer).Encode(response)
@@ -697,8 +720,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
} }
response := map[string]any{ response := map[string]any{
"logs": SanitizeLogs(logs), jsonKeyLogs: SanitizeLogs(logs),
"status": status, jsonKeyStatus: status,
} }
_ = json.NewEncoder(writer).Encode(response) _ = json.NewEncoder(writer).Encode(response)
@@ -732,7 +755,7 @@ func (h *Handlers) HandleAppStatusAPI() http.HandlerFunc {
} }
response := map[string]any{ response := map[string]any{
"status": string(application.Status), jsonKeyStatus: string(application.Status),
"latestDeploymentID": latestDeploymentID, "latestDeploymentID": latestDeploymentID,
"latestDeploymentStatus": latestDeploymentStatus, "latestDeploymentStatus": latestDeploymentStatus,
} }
@@ -769,7 +792,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,
"status": string(d.Status), jsonKeyStatus: string(d.Status),
"duration": d.Duration(), "duration": d.Duration(),
"shortCommit": d.ShortCommit(), "shortCommit": d.ShortCommit(),
"finishedAtISO": d.FinishedAtISO(), "finishedAtISO": d.FinishedAtISO(),
@@ -969,7 +992,7 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
decodeErr := json.NewDecoder(request.Body).Decode(&pairs) decodeErr := json.NewDecoder(request.Body).Decode(&pairs)
if decodeErr != nil { if decodeErr != nil {
h.respondJSON(writer, request, map[string]string{ h.respondJSON(writer, request, map[string]string{
"error": "invalid request body", jsonKeyError: "invalid request body",
}, http.StatusBadRequest) }, http.StatusBadRequest)
return return
@@ -978,7 +1001,7 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
modelPairs, validationErr := validateEnvPairs(pairs) modelPairs, validationErr := validateEnvPairs(pairs)
if validationErr != "" { if validationErr != "" {
h.respondJSON(writer, request, map[string]string{ h.respondJSON(writer, request, map[string]string{
"error": validationErr, jsonKeyError: validationErr,
}, http.StatusBadRequest) }, http.StatusBadRequest)
return return
@@ -990,7 +1013,7 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
if replaceErr != nil { if replaceErr != nil {
h.log.Error("failed to replace env vars", "error", replaceErr) h.log.Error("failed to replace env vars", "error", replaceErr)
h.respondJSON(writer, request, map[string]string{ h.respondJSON(writer, request, map[string]string{
"error": "failed to save environment variables", jsonKeyError: "failed to save environment variables",
}, http.StatusInternalServerError) }, http.StatusInternalServerError)
return return
@@ -1018,32 +1041,77 @@ func (h *Handlers) HandleLabelAdd() http.HandlerFunc {
} }
} }
// HandleLabelDelete handles deleting a label. // deleteAppResource handles deletion of an app-owned resource (label,
func (h *Handlers) HandleLabelDelete() http.HandlerFunc { // volume, or port) identified by an int64 URL parameter. The
return func(writer http.ResponseWriter, request *http.Request) { // deleteByID closure reports whether the resource was found to belong
// 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")
labelIDStr := chi.URLParam(request, "labelID") idStr := chi.URLParam(request, idParam)
labelID, parseErr := strconv.ParseInt(labelIDStr, 10, 64) id, parseErr := strconv.ParseInt(idStr, 10, 64)
if parseErr != nil { if parseErr != nil {
http.NotFound(writer, request) http.NotFound(writer, request)
return return
} }
label, findErr := models.FindLabel(request.Context(), h.db, labelID) found, deleteErr := deleteByID(request.Context(), appID, id)
if findErr != nil || label == nil || label.AppID != appID { if !found {
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 label", "error", deleteErr) h.log.Error("failed to delete "+logName, "error", deleteErr)
} }
redirectToApp(writer, request, appID, "") redirectToApp(writer, request, appID, "")
}
// 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,
),
)
} }
} }
@@ -1102,29 +1170,13 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
// 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) {
appID := chi.URLParam(request, "id") h.deleteAppResource(
volumeIDStr := chi.URLParam(request, "volumeID") writer, request, "volumeID", "volume",
makeDeleteByID(h.db, models.FindVolume,
volumeID, parseErr := strconv.ParseInt(volumeIDStr, 10, 64) func(v *models.Volume) string { return v.AppID },
if parseErr != nil { (*models.Volume).Delete,
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)
}
redirectToApp(writer, request, appID, "")
} }
} }
@@ -1197,29 +1249,13 @@ 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) {
appID := chi.URLParam(request, "id") h.deleteAppResource(
portIDStr := chi.URLParam(request, "portID") writer, request, "portID", "port",
makeDeleteByID(h.db, models.FindPort,
portID, parseErr := strconv.ParseInt(portIDStr, 10, 64) func(p *models.Port) string { return p.AppID },
if parseErr != nil { (*models.Port).Delete,
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)
}
redirectToApp(writer, request, appID, "")
} }
} }
@@ -1396,8 +1432,9 @@ func optionalNullString(s string) sql.NullString {
return sql.NullString{} return sql.NullString{}
} }
// applyResourceLimits parses CPU and memory limit form values and applies them to the app. // applyResourceLimits parses CPU and memory limit form values and
// Returns an error message string if validation fails, or empty string on success. // 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 { func applyResourceLimits(application *models.App, request *http.Request) string {
cpuLimit, cpuErr := parseOptionalFloat64(request.FormValue("cpu_limit")) cpuLimit, cpuErr := parseOptionalFloat64(request.FormValue("cpu_limit"))
if cpuErr != nil { if cpuErr != nil {
@@ -1432,7 +1469,8 @@ func memoryUnitMultiplier(suffix byte) int64 {
} }
// parseOptionalFloat64 parses an optional float64 form field. // parseOptionalFloat64 parses an optional float64 form field.
// Returns a valid NullFloat64 if the string is non-empty and parses to a positive number. // 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 empty NullFloat64 if the string is empty.
// Returns an error if the string is non-empty but invalid or non-positive. // Returns an error if the string is non-empty but invalid or non-positive.
func parseOptionalFloat64(s string) (sql.NullFloat64, error) { func parseOptionalFloat64(s string) (sql.NullFloat64, error) {
@@ -1454,7 +1492,8 @@ func parseOptionalFloat64(s string) (sql.NullFloat64, error) {
} }
// parseOptionalMemoryBytes parses an optional memory limit string into bytes. // parseOptionalMemoryBytes parses an optional memory limit string into bytes.
// Accepts plain bytes (e.g. "536870912") or suffixed values (e.g. "512m", "1g", "256k"). // 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. // Returns a valid NullInt64 with bytes if non-empty, empty NullInt64 if blank.
func parseOptionalMemoryBytes(s string) (sql.NullInt64, error) { func parseOptionalMemoryBytes(s string) (sql.NullInt64, error) {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
+10 -2
View File
@@ -21,8 +21,16 @@ 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}, {
{"64 chars", "a234567890123456789012345678901234567890123456789012345678901234", true}, "exactly 63 chars",
"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},
+13
View File
@@ -22,6 +22,19 @@ import (
"sneak.berlin/go/upaas/templates" "sneak.berlin/go/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.
type Params struct { type Params struct {
fx.In fx.In
+75 -50
View File
@@ -8,7 +8,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -32,11 +31,18 @@ import (
"sneak.berlin/go/upaas/internal/service/webhook" "sneak.berlin/go/upaas/internal/service/webhook"
) )
const (
branchMain = "main"
paramSecret = "secret"
)
type testContext struct { type testContext struct {
handlers *handlers.Handlers handlers *handlers.Handlers
database *database.Database database *database.Database
authSvc *auth.Service authSvc *auth.Service
appSvc *app.Service appSvc *app.Service
deploySvc *deploy.Service
webhookSvc *webhook.Service
middleware *middleware.Middleware middleware *middleware.Middleware
} }
@@ -181,6 +187,8 @@ func setupTestHandlers(t *testing.T) *testContext {
database: dbInstance, database: dbInstance,
authSvc: authSvc, authSvc: authSvc,
appSvc: appSvc, appSvc: appSvc,
deploySvc: deploySvc,
webhookSvc: webhookSvc,
middleware: mw, middleware: mw,
} }
} }
@@ -211,6 +219,26 @@ 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()
@@ -218,31 +246,20 @@ func TestHandleSetupGET(t *testing.T) {
t.Parallel() t.Parallel()
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
assertPageRenders(t, testCtx.handlers.HandleSetupGET(), "/setup", "setup")
request := httptest.NewRequestWithContext(t.Context(), 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")
}) })
} }
func createSetupFormRequest( func createSetupFormRequest(
t *testing.T,
username, password, confirm string, username, password, confirm string,
) *http.Request { ) *http.Request {
t.Helper()
form := url.Values{} form := url.Values{}
form.Set("username", username) form.Set("username", username)
form.Set("password", password) form.Set("password", password)
form.Set("password_confirm", confirm) form.Set("password_confirm", confirm)
request := httptest.NewRequestWithContext( request := httptest.NewRequestWithContext(
t.Context(), context.Background(),
http.MethodPost, http.MethodPost,
"/setup", "/setup",
strings.NewReader(form.Encode()), strings.NewReader(form.Encode()),
@@ -257,7 +274,7 @@ func TestHandleSetupPOSTCreatesUserAndRedirects(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := createSetupFormRequest(t, "admin", "password123", "password123") request := createSetupFormRequest("admin", "password123", "password123")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST() handler := testCtx.handlers.HandleSetupPOST()
@@ -272,7 +289,7 @@ func TestHandleSetupPOSTRejectsEmptyUsername(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := createSetupFormRequest(t, "", "password123", "password123") request := createSetupFormRequest("", "password123", "password123")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST() handler := testCtx.handlers.HandleSetupPOST()
@@ -287,7 +304,7 @@ func TestHandleSetupPOSTRejectsShortPassword(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := createSetupFormRequest(t, "admin", "short", "short") request := createSetupFormRequest("admin", "short", "short")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST() handler := testCtx.handlers.HandleSetupPOST()
@@ -302,7 +319,7 @@ func TestHandleSetupPOSTRejectsMismatchedPasswords(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := createSetupFormRequest(t, "admin", "password123", "different123") request := createSetupFormRequest("admin", "password123", "different123")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST() handler := testCtx.handlers.HandleSetupPOST()
@@ -319,27 +336,17 @@ func TestHandleLoginGET(t *testing.T) {
t.Parallel() t.Parallel()
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
assertPageRenders(t, testCtx.handlers.HandleLoginGET(), "/login", "login")
request := httptest.NewRequestWithContext(t.Context(), 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")
}) })
} }
func createLoginFormRequest(t *testing.T, username, password string) *http.Request { func createLoginFormRequest(username, password string) *http.Request {
t.Helper()
form := url.Values{} form := url.Values{}
form.Set("username", username) form.Set("username", username)
form.Set("password", password) form.Set("password", password)
request := httptest.NewRequestWithContext( request := httptest.NewRequestWithContext(
t.Context(), context.Background(),
http.MethodPost, http.MethodPost,
"/login", "/login",
strings.NewReader(form.Encode()), strings.NewReader(form.Encode()),
@@ -362,7 +369,7 @@ func TestHandleLoginPOSTAuthenticatesValidCredentials(t *testing.T) {
) )
require.NoError(t, createErr) require.NoError(t, createErr)
request := createLoginFormRequest(t, "testuser", "testpass123") request := createLoginFormRequest("testuser", "testpass123")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginPOST() handler := testCtx.handlers.HandleLoginPOST()
@@ -385,7 +392,7 @@ func TestHandleLoginPOSTRejectsInvalidCredentials(t *testing.T) {
) )
require.NoError(t, createErr) require.NoError(t, createErr)
request := createLoginFormRequest(t, "testuser", "wrongpassword") request := createLoginFormRequest("testuser", "wrongpassword")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginPOST() handler := testCtx.handlers.HandleLoginPOST()
@@ -403,7 +410,9 @@ func TestHandleDashboard(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard() handler := testCtx.handlers.HandleDashboard()
@@ -421,7 +430,9 @@ func TestHandleDashboard(t *testing.T) {
// Create an app so the template iterates over AppStats and hits .CSRFField // Create an app so the template iterates over AppStats and hits .CSRFField
createTestApp(t, testCtx, "csrf-test-app") createTestApp(t, testCtx, "csrf-test-app")
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard() handler := testCtx.handlers.HandleDashboard()
@@ -482,7 +493,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: "main", Branch: branchMain,
}, },
) )
require.NoError(t, err) require.NoError(t, err)
@@ -503,7 +514,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: "main", Branch: branchMain,
}, },
) )
require.NoError(t, createErr) require.NoError(t, createErr)
@@ -519,7 +530,7 @@ func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
) )
request = addChiURLParams( request = addChiURLParams(
request, request,
map[string]string{"secret": createdApp.WebhookSecret}, map[string]string{paramSecret: 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")
@@ -688,7 +699,8 @@ func TestHandleEnvVarSaveDuplicateKeyRejected(t *testing.T) {
createdApp := createTestApp(t, testCtx, "envvar-dedup-app") createdApp := createTestApp(t, testCtx, "envvar-dedup-app")
// Send two entries with the same key — should be rejected // Send two entries with the same key — should be rejected
body := `[{"key":"FOO","value":"first"},{"key":"BAR","value":"bar"},{"key":"FOO","value":"second"}]` body := `[{"key":"FOO","value":"first"},{"key":"BAR","value":"bar"},` +
`{"key":"FOO","value":"second"}]`
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
@@ -1038,7 +1050,8 @@ func TestHandleVolumeAddValidatesPaths(t *testing.T) {
} }
// TestSetupRequiredExemptsHealthAndStaticAndAPI verifies that the SetupRequired // TestSetupRequiredExemptsHealthAndStaticAndAPI verifies that the SetupRequired
// middleware allows /health, /s/*, and /api/* paths through even when setup is required. // middleware allows /health, /s/*, and /api/* paths through even when setup is
// required.
func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) { func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1054,13 +1067,21 @@ func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
wrapped := mw(okHandler) wrapped := mw(okHandler)
exemptPaths := []string{"/health", "/s/style.css", "/s/js/app.js", "/api/v1/apps", "/api/v1/login"} exemptPaths := []string{
"/health",
"/s/style.css",
"/s/js/app.js",
"/api/v1/apps",
"/api/v1/login",
}
for _, path := range exemptPaths { for _, path := range exemptPaths {
t.Run(path, func(t *testing.T) { t.Run(path, func(t *testing.T) {
t.Parallel() t.Parallel()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil) req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil,
)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req) wrapped.ServeHTTP(rr, req)
@@ -1073,7 +1094,9 @@ func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
t.Run("non-exempt redirects", func(t *testing.T) { t.Run("non-exempt redirects", func(t *testing.T) {
t.Parallel() t.Parallel()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req) wrapped.ServeHTTP(rr, req)
@@ -1138,7 +1161,10 @@ func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
webhookURL, webhookURL,
strings.NewReader(payload), strings.NewReader(payload),
) )
request = addChiURLParams(request, map[string]string{"secret": "unknown-secret"}) request = addChiURLParams(
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")
@@ -1161,7 +1187,7 @@ 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: "main", Branch: branchMain,
}, },
) )
require.NoError(t, createErr) require.NoError(t, createErr)
@@ -1176,7 +1202,7 @@ func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
) )
request = addChiURLParams( request = addChiURLParams(
request, request,
map[string]string{"secret": createdApp.WebhookSecret}, map[string]string{paramSecret: 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")
@@ -1188,8 +1214,7 @@ func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, http.StatusOK, recorder.Code)
// Allow async deployment goroutine to complete before test cleanup. // Wait for the async deployment goroutine to finish so its writes
// The deployment will fail quickly (docker not connected) but we need // under the temp dir complete before test cleanup.
// to wait for it to finish to avoid temp directory cleanup race. testCtx.webhookSvc.WaitForDeployments()
time.Sleep(100 * time.Millisecond)
} }
+121
View File
@@ -0,0 +1,121 @@
package handlers_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/models"
)
// doLogDownload issues a log-download request for the given app and
// deployment and returns the recorder.
func doLogDownload(
t *testing.T,
testCtx *testContext,
appID string,
deploymentID int64,
) *httptest.ResponseRecorder {
t.Helper()
idStr := strconv.FormatInt(deploymentID, 10)
request := httptest.NewRequestWithContext(
t.Context(),
http.MethodGet,
"/apps/"+appID+"/deployments/"+idStr+"/log",
nil,
)
request = addChiURLParams(request, map[string]string{
"id": appID,
"deploymentID": idStr,
})
recorder := httptest.NewRecorder()
testCtx.handlers.HandleDeploymentLogDownload().ServeHTTP(recorder, request)
return recorder
}
// TestHandleDeploymentLogDownloadServesLegitimateFile verifies a normal
// log file is served for download.
func TestHandleDeploymentLogDownloadServesLegitimateFile(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "log-download-app")
deployment := models.NewDeployment(testCtx.database)
deployment.AppID = createdApp.ID
deployment.Status = models.DeploymentStatusSuccess
require.NoError(t, deployment.Save(context.Background()))
// Write the log file where the handler will look for it.
logPath := testCtx.deploySvc.GetLogFilePath(createdApp, deployment)
require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0o750))
require.NoError(t, os.WriteFile(logPath, []byte("deploy log contents"), 0o600))
recorder := doLogDownload(t, testCtx, createdApp.ID, deployment.ID)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "deploy log contents")
}
// TestHandleDeploymentLogDownloadRejectsPathTraversal verifies the
// os.Root containment guard. A traversal-shaped app name drives the
// resolved log path out of the deploy log directory onto a sentinel
// file that really exists. The handler must refuse to serve it (404)
// rather than leak its contents. Removing the guard makes this test
// fail, which the earlier version — pointed at a non-existent path that
// 404s either way — did not.
func TestHandleDeploymentLogDownloadRejectsPathTraversal(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "log-traversal-app")
createdApp.Name = "../.."
require.NoError(t, createdApp.Save(context.Background()))
// The log root must exist so os.OpenRoot succeeds and the rejection
// comes from the containment check, not a missing directory.
logDir := testCtx.deploySvc.GetLogDir()
require.NoError(t, os.MkdirAll(logDir, 0o750))
deployment := models.NewDeployment(testCtx.database)
deployment.AppID = createdApp.ID
deployment.Status = models.DeploymentStatusSuccess
require.NoError(t, deployment.Save(context.Background()))
// Where the handler resolves the log path to. The traversal name
// makes this land outside logDir; require that it truly escapes so
// the test cannot silently stop covering the guard.
escapedPath := testCtx.deploySvc.GetLogFilePath(createdApp, deployment)
relPath, relErr := filepath.Rel(logDir, escapedPath)
require.NoError(t, relErr)
require.True(t, strings.HasPrefix(relPath, ".."),
"resolved path must escape the log dir, got %q", relPath)
// Plant a sentinel where the traversal points; a missing guard would
// open and serve it.
require.NoError(t, os.MkdirAll(filepath.Dir(escapedPath), 0o750))
const sentinel = "SENTINEL-outside-log-dir-must-not-be-served"
require.NoError(t, os.WriteFile(escapedPath, []byte(sentinel), 0o600))
t.Cleanup(func() { _ = os.Remove(escapedPath) })
recorder := doLogDownload(t, testCtx, createdApp.ID, deployment.ID)
assert.Equal(t, http.StatusNotFound, recorder.Code)
assert.NotContains(t, recorder.Body.String(), sentinel,
"containment guard must not serve a file outside the log dir")
}
+6 -2
View File
@@ -16,7 +16,9 @@ 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(t.Context(), http.MethodGet, "/setup", nil) request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/setup", nil,
)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupGET() handler := testCtx.handlers.HandleSetupGET()
@@ -59,7 +61,9 @@ func TestLoginRenderTemplateBuffersOutput(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/login", nil) request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/login", nil,
)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginGET() handler := testCtx.handlers.HandleLoginGET()
+9 -4
View File
@@ -11,13 +11,17 @@ import (
var ( var (
errRepoURLEmpty = errors.New("repository URL must not be empty") errRepoURLEmpty = errors.New("repository URL must not be empty")
errRepoURLScheme = errors.New("file:// URLs are not allowed for security reasons") 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") 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") errRepoURLNoHost = errors.New("repository URL must include a host")
errRepoURLNoPath = errors.New("repository URL must include a path") 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). // scpLikeRepoRe matches SCP-like git URLs: git@host:path
// Only the "git" user is allowed, as that is the standard for SSH deploy keys. // (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._-]+:.+$`) var scpLikeRepoRe = regexp.MustCompile(`^git@[a-zA-Z0-9._-]+:.+$`)
// allowedRepoSchemes lists the URL schemes accepted for repository URLs. // allowedRepoSchemes lists the URL schemes accepted for repository URLs.
@@ -30,7 +34,8 @@ var allowedRepoSchemes = map[string]bool{
"git": true, "git": true,
} }
// validateRepoURL checks that the given repository URL is valid and uses an allowed scheme. // validateRepoURL checks that the given repository URL is valid and
// uses an allowed scheme.
func validateRepoURL(repoURL string) error { func validateRepoURL(repoURL string) error {
if strings.TrimSpace(repoURL) == "" { if strings.TrimSpace(repoURL) == "" {
return errRepoURLEmpty return errRepoURLEmpty
+20 -4
View File
@@ -22,7 +22,11 @@ func TestValidateRepoURL(t *testing.T) {
{name: "SCP-like 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: "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 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}, {
name: "https with port",
url: "https://git.example.com:8443/user/repo.git",
wantErr: false,
},
// Invalid URLs // Invalid URLs
{name: "empty string", url: "", wantErr: true}, {name: "empty string", url: "", wantErr: true},
@@ -37,10 +41,22 @@ func TestValidateRepoURL(t *testing.T) {
{name: "no path https", url: "https://github.com", 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: "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 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: "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 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}, 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 { for _, tc := range tests {
+5 -2
View File
@@ -5,8 +5,11 @@ import (
"strings" "strings"
) )
// ansiEscapePattern matches ANSI escape sequences (CSI, OSC, and single-character escapes). // ansiEscapePattern matches ANSI escape sequences (CSI, OSC, and
var ansiEscapePattern = regexp.MustCompile(`(\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]])`) // 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 // SanitizeLogs strips ANSI escape sequences and non-printable control characters
// from container log output. Newlines (\n), carriage returns (\r), and tabs (\t) // from container log output. Newlines (\n), carriage returns (\r), and tabs (\t)
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"sneak.berlin/go/upaas/internal/handlers" "sneak.berlin/go/upaas/internal/handlers"
) )
func TestSanitizeLogs(t *testing.T) { //nolint:funlen // table-driven tests func TestSanitizeLogs(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
+1 -1
View File
@@ -56,7 +56,7 @@ func (h *Handlers) renderSetupError(
) { ) {
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"Username": username, "Username": username,
"Error": errorMsg, dataKeyError: errorMsg,
}, request) }, request)
h.renderTemplate(writer, tmpl, "setup.html", data) h.renderTemplate(writer, tmpl, "setup.html", data)
} }
+1 -1
View File
@@ -47,7 +47,7 @@ func (h *Handlers) HandleAppWebhookEvents() http.HandlerFunc {
} }
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"App": application, dataKeyApp: application,
"Events": events, "Events": events,
}, request) }, request)
+15 -17
View File
@@ -24,21 +24,30 @@ func newCORSTestMiddleware(corsOrigins string) *Middleware {
} }
} }
func TestCORS_NoOriginsConfigured_NoCORSHeaders(t *testing.T) { // assertNoCORSHeaders runs a request with the given Origin header through
t.Parallel() // 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("") m := newCORSTestMiddleware(corsOrigins)
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) }))
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://evil.com") req.Header.Set("Origin", origin)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"), 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") "expected no CORS headers when no origins configured")
} }
@@ -65,17 +74,6 @@ func TestCORS_OriginsConfigured_AllowsMatchingOrigin(t *testing.T) {
func TestCORS_OriginsConfigured_RejectsNonMatchingOrigin(t *testing.T) { func TestCORS_OriginsConfigured_RejectsNonMatchingOrigin(t *testing.T) {
t.Parallel() t.Parallel()
m := newCORSTestMiddleware("https://app.example.com") assertNoCORSHeaders(t, "https://app.example.com", "https://evil.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://evil.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"),
"expected no CORS headers for non-matching origin") "expected no CORS headers for non-matching origin")
} }
+67
View File
@@ -0,0 +1,67 @@
package middleware //nolint:testpackage // tests internal CSRF 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 newCSRFTestMiddleware(plaintextHTTP bool) *Middleware {
return &Middleware{
log: slog.Default(),
params: &Params{
Config: &config.Config{
SessionSecret: "test-secret-32-bytes-long-enough",
PlaintextHTTP: plaintextHTTP,
},
},
}
}
// postWithPlainHTTPOrigin drives a tokenless POST carrying a plain-HTTP Origin
// through the CSRF middleware and returns the "Forbidden - <reason>" body.
// gorilla/csrf checks the Origin before the token, so the reason reveals which
// check rejected the request.
func postWithPlainHTTPOrigin(t *testing.T, plaintextHTTP bool) string {
t.Helper()
m := newCSRFTestMiddleware(plaintextHTTP)
handler := m.CSRF()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequestWithContext(
t.Context(), http.MethodPost, "http://example.com/setup", nil)
req.Header.Set("Origin", "http://example.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
return rec.Body.String()
}
// Without PlaintextHTTP the origin check assumes https and rejects a browser's
// http:// Origin, which is what broke setup over plain HTTP.
func TestCSRF_PlaintextDisabled_RejectsPlainHTTPOrigin(t *testing.T) {
t.Parallel()
assert.Contains(t, postWithPlainHTTPOrigin(t, false), "origin invalid")
}
// With PlaintextHTTP the origin check uses http, so a matching http:// Origin
// passes it and the request only fails later for the missing token.
func TestCSRF_PlaintextEnabled_AllowsPlainHTTPOrigin(t *testing.T) {
t.Parallel()
body := postWithPlainHTTPOrigin(t, true)
assert.NotContains(t, body, "origin invalid")
assert.Contains(t, body, "CSRF token not found")
}
+27 -4
View File
@@ -255,12 +255,34 @@ func (m *Middleware) SessionAuth() func(http.Handler) http.Handler {
} }
// CSRF returns CSRF protection middleware using gorilla/csrf. // CSRF returns CSRF protection middleware using gorilla/csrf.
//
// gorilla/csrf assumes the request scheme is https for its same-origin check
// unless the request is marked plaintext. A TLS-terminating reverse proxy
// (the default deployment) presents https to the browser, so the default is
// correct there. When µPaaS is reached over plain HTTP — directly, or behind a
// proxy that does not terminate TLS — set UPAAS_PLAINTEXT_HTTP so the origin
// check compares against http:// and setup over plain HTTP works.
func (m *Middleware) CSRF() func(http.Handler) http.Handler { func (m *Middleware) CSRF() func(http.Handler) http.Handler {
return csrf.Protect( protect := csrf.Protect(
[]byte(m.params.Config.SessionSecret), []byte(m.params.Config.SessionSecret),
csrf.Secure(false), // Allow HTTP for development; reverse proxy handles TLS csrf.Secure(false), // cookie Secure flag; TLS is terminated upstream
csrf.Path("/"), csrf.Path("/"),
) )
if !m.params.Config.PlaintextHTTP {
return protect
}
return func(next http.Handler) http.Handler {
protected := protect(next)
return http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
protected.ServeHTTP(writer, csrf.PlaintextHTTPRequest(request))
})
}
} }
// loginRateLimit configures the login rate limiter. // loginRateLimit configures the login rate limiter.
@@ -370,8 +392,9 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
} }
} }
// APISessionAuth returns middleware that requires session authentication for API routes. // APISessionAuth returns middleware that requires session authentication
// Unlike SessionAuth, it returns JSON 401 responses instead of redirecting to /login. // for API routes. Unlike SessionAuth, it returns JSON 401 responses instead
// 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(
+18 -10
View File
@@ -30,9 +30,11 @@ func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
mw := newTestMiddleware(t) mw := newTestMiddleware(t)
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { handler := mw.LoginRateLimit()(http.HandlerFunc(
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 {
@@ -48,7 +50,8 @@ func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
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, "6th request should be rate limited") assert.Equal(t, http.StatusTooManyRequests, rec.Code,
"6th request should be rate limited")
} }
//nolint:paralleltest // mutates global loginLimiter //nolint:paralleltest // mutates global loginLimiter
@@ -57,21 +60,23 @@ func TestLoginRateLimitIsolatesIPs(t *testing.T) {
mw := newTestMiddleware(t) mw := newTestMiddleware(t)
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { handler := mw.LoginRateLimit()(http.HandlerFunc(
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.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req.RemoteAddr = "10.0.0.1:1234" req.RemoteAddr = testProxyAddr
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.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req.RemoteAddr = "10.0.0.1:1234" req.RemoteAddr = testProxyAddr
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)
@@ -90,9 +95,11 @@ func TestLoginRateLimitReturns429Body(t *testing.T) {
mw := newTestMiddleware(t) mw := newTestMiddleware(t)
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { handler := mw.LoginRateLimit()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) },
))
// Exhaust burst // Exhaust burst
for range 5 { for range 5 {
@@ -108,7 +115,8 @@ func TestLoginRateLimitReturns429Body(t *testing.T) {
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"), "should include Retry-After header") assert.NotEmpty(t, rec.Header().Get("Retry-After"),
"should include Retry-After header")
} }
func TestIPLimiterEvictsStaleEntries(t *testing.T) { func TestIPLimiterEvictsStaleEntries(t *testing.T) {
+37 -25
View File
@@ -7,6 +7,16 @@ 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()
@@ -20,63 +30,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: "10.0.0.1:1234", remoteAddr: testProxyAddr,
xRealIP: "203.0.113.5", xRealIP: testRealIP,
xff: "198.51.100.1, 10.0.0.1", xff: "198.51.100.1, 10.0.0.1",
want: "203.0.113.5", want: testRealIP,
}, },
{ {
name: "trusted: XFF from 10.x when no X-Real-IP", name: "trusted: XFF from 10.x when no X-Real-IP",
remoteAddr: "10.0.0.1:1234", remoteAddr: testProxyAddr,
xff: "198.51.100.1, 10.0.0.1", xff: "198.51.100.1, 10.0.0.1",
want: "198.51.100.1", want: testXFFIP,
}, },
{ {
name: "trusted: XFF single IP from 10.x", name: "trusted: XFF single IP from 10.x",
remoteAddr: "10.0.0.1:1234", remoteAddr: testProxyAddr,
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: "192.168.1.1", want: testPrivateIP,
}, },
{ {
name: "trusted: RemoteAddr without port", name: "trusted: RemoteAddr without port",
remoteAddr: "192.168.1.1", remoteAddr: testPrivateIP,
want: "192.168.1.1", want: testPrivateIP,
}, },
{ {
name: "trusted: X-Real-IP with whitespace from 10.x", name: "trusted: X-Real-IP with whitespace from 10.x",
remoteAddr: "10.0.0.1:1234", remoteAddr: testProxyAddr,
xRealIP: " 203.0.113.5 ", xRealIP: " 203.0.113.5 ",
want: "203.0.113.5", want: testRealIP,
}, },
{ {
name: "trusted: XFF with whitespace from 10.x", name: "trusted: XFF with whitespace from 10.x",
remoteAddr: "10.0.0.1:1234", remoteAddr: testProxyAddr,
xff: " 198.51.100.1 , 10.0.0.1", xff: " 198.51.100.1 , 10.0.0.1",
want: "198.51.100.1", want: testXFFIP,
}, },
{ {
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: "10.0.0.1:1234", remoteAddr: testProxyAddr,
xRealIP: " ", xRealIP: " ",
xff: "198.51.100.1", xff: testXFFIP,
want: "198.51.100.1", want: testXFFIP,
}, },
{ {
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: "93.184.216.34", xRealIP: testPublicIP,
want: "93.184.216.34", want: testPublicIP,
}, },
{ {
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: "8.8.8.8", xff: testPublicDNSIP,
want: "8.8.8.8", want: testPublicDNSIP,
}, },
// === Untrusted proxy (public IP) — headers IGNORED, use RemoteAddr === // === Untrusted proxy (public IP) — headers IGNORED, use RemoteAddr ===
@@ -97,17 +107,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: "8.8.8.8", want: testPublicDNSIP,
}, },
{ {
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: "93.184.216.34", want: testPublicIP,
}, },
{ {
name: "untrusted: public RemoteAddr without port", name: "untrusted: public RemoteAddr without port",
remoteAddr: "93.184.216.34", remoteAddr: testPublicIP,
want: "93.184.216.34", want: testPublicIP,
}, },
} }
@@ -139,7 +149,9 @@ 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{"8.8.8.8", "203.0.113.1", "172.32.0.1", "11.0.0.1", "2001:db8::1"} untrusted := []string{
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)
+36 -23
View File
@@ -93,6 +93,41 @@ 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,
@@ -103,29 +138,7 @@ 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`
rows, err := db.Query(ctx, query, appID) return findAllByAppID(ctx, db, query, appID, "env vars", NewEnvVar)
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. // EnvVarPair is a key-value pair for bulk env var operations.
+5 -21
View File
@@ -93,6 +93,10 @@ 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,
@@ -103,27 +107,7 @@ 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`
rows, err := db.Query(ctx, query, appID) return findAllByAppID(ctx, db, query, appID, "labels", NewLabel)
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.
+54 -36
View File
@@ -317,11 +317,16 @@ func TestAllApps(t *testing.T) {
// EnvVar Tests. // EnvVar Tests.
func TestEnvVarCRUD(t *testing.T) { // testKVCreateAndFind exercises the create-and-find round trip shared
t.Parallel() // by key-value models (env vars, labels).
func testKVCreateAndFind[T any](
t.Run("creates and finds env vars", func(t *testing.T) { t *testing.T,
t.Parallel() wantKey string,
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()
@@ -329,21 +334,37 @@ func TestEnvVarCRUD(t *testing.T) {
// Create app first. // Create app first.
app := createTestApp(t, testDB) app := createTestApp(t, testDB)
envVar := models.NewEnvVar(testDB) id, err := create(testDB, app.ID)
envVar.AppID = app.ID require.NoError(t, err)
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)
envVars, err := models.FindEnvVarsByAppID( return envVar.ID, err
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) {
@@ -375,32 +396,27 @@ 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()
testDB, cleanup := setupTestDB(t) testKVCreateAndFind(t, "traefik.enable", saveTestLabel,
defer cleanup() models.FindLabelsByAppID,
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)
}) })
} }
@@ -569,7 +585,9 @@ func TestDeploymentFindByAppID(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
deployments, err := models.FindDeploymentsByAppID(context.Background(), testDB, app.ID, 3) deployments, err := models.FindDeploymentsByAppID(
context.Background(), testDB, app.ID, 3,
)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, deployments, 3) assert.Len(t, deployments, 3)
} }
@@ -706,7 +724,6 @@ 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()
@@ -783,7 +800,8 @@ func TestCascadeDelete(t *testing.T) {
// Resource Limits Tests. // Resource Limits Tests.
func TestAppResourceLimits(t *testing.T) { //nolint:funlen // integration test with multiple subtests //nolint:funlen // integration test with multiple subtests
func TestAppResourceLimits(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("saves and loads CPU limit", func(t *testing.T) { t.Run("saves and loads CPU limit", func(t *testing.T) {
+7 -24
View File
@@ -112,6 +112,12 @@ 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,
@@ -122,30 +128,7 @@ 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`
rows, err := db.Query(ctx, query, appID) return findAllByAppID(ctx, db, query, appID, "ports", NewPort)
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.
+7 -24
View File
@@ -103,6 +103,12 @@ 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,
@@ -113,30 +119,7 @@ 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`
rows, err := db.Query(ctx, query, appID) return findAllByAppID(ctx, db, query, appID, "volumes", NewVolume)
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.
+8 -2
View File
@@ -71,8 +71,14 @@ func (s *Server) SetupRoutes() {
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}/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())
+77 -47
View File
@@ -16,6 +16,12 @@ import (
"sneak.berlin/go/upaas/internal/service/app" "sneak.berlin/go/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()
@@ -58,7 +64,8 @@ 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 verifies it's gone. // It creates an app, adds an item, verifies it exists, deletes it, and
// verifies it's gone.
func deleteItemTestHelper( func deleteItemTestHelper(
t *testing.T, t *testing.T,
appName string, appName string,
@@ -73,7 +80,7 @@ func deleteItemTestHelper(
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{ createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: appName, Name: appName,
RepoURL: "git@example.com:user/repo.git", RepoURL: testRepoURL,
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -92,6 +99,35 @@ 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()
@@ -100,7 +136,7 @@ func TestCreateAppWithGeneratedKeys(t *testing.T) {
input := app.CreateAppInput{ input := app.CreateAppInput{
Name: "test-app", Name: "test-app",
RepoURL: "git@gitea.example.com:user/repo.git", RepoURL: giteaRepoURL,
Branch: "main", Branch: "main",
DockerfilePath: "Dockerfile", DockerfilePath: "Dockerfile",
} }
@@ -110,7 +146,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, "git@gitea.example.com:user/repo.git", createdApp.RepoURL) assert.Equal(t, giteaRepoURL, 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)
@@ -130,7 +166,7 @@ func TestCreateAppDefaults(t *testing.T) {
input := app.CreateAppInput{ input := app.CreateAppInput{
Name: "test-app-defaults", Name: "test-app-defaults",
RepoURL: "git@gitea.example.com:user/repo.git", RepoURL: giteaRepoURL,
} }
createdApp, err := svc.CreateApp(context.Background(), input) createdApp, err := svc.CreateApp(context.Background(), input)
@@ -148,7 +184,7 @@ func TestCreateAppOptionalFields(t *testing.T) {
input := app.CreateAppInput{ input := app.CreateAppInput{
Name: "test-app-full", Name: "test-app-full",
RepoURL: "git@gitea.example.com:user/repo.git", RepoURL: giteaRepoURL,
Branch: "develop", Branch: "develop",
DockerNetwork: "my-network", DockerNetwork: "my-network",
NtfyTopic: "https://ntfy.sh/my-topic", NtfyTopic: "https://ntfy.sh/my-topic",
@@ -176,7 +212,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: "git@example.com:user/repo.git", RepoURL: testRepoURL,
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -208,7 +244,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: "git@example.com:user/repo.git", RepoURL: testRepoURL,
NtfyTopic: "https://ntfy.sh/topic", NtfyTopic: "https://ntfy.sh/topic",
SlackWebhook: "https://slack.com/hook", SlackWebhook: "https://slack.com/hook",
}) })
@@ -216,7 +252,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: "git@example.com:user/repo.git", RepoURL: testRepoURL,
Branch: "main", Branch: "main",
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -240,7 +276,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: "git@example.com:user/repo.git", RepoURL: testRepoURL,
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -264,7 +300,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: "git@example.com:user/repo.git", RepoURL: testRepoURL,
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -299,7 +335,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: "git@example.com:user/repo.git", RepoURL: testRepoURL,
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -378,7 +414,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: "git@example.com:user/repo.git", RepoURL: testRepoURL,
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -411,29 +447,33 @@ 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()
deleteItemTestHelper(t, "env-delete-test", runDeleteItemTest(t, "env-delete-test", addDeletableEnvVar,
func(ctx context.Context, svc *app.Service, appID string) error { func(ctx context.Context, application *models.App) ([]*models.EnvVar, error) {
return svc.AddEnvVar(ctx, appID, "TO_DELETE", "value") return application.GetEnvVars(ctx)
}, },
func(ctx context.Context, application *models.App) (int, error) { func(ctx context.Context, svc *app.Service, item *models.EnvVar) error {
envVars, err := application.GetEnvVars(ctx) return svc.DeleteEnvVar(ctx, item.ID)
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()
@@ -445,7 +485,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: "git@example.com:user/repo.git", RepoURL: testRepoURL,
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -468,22 +508,12 @@ 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()
deleteItemTestHelper(t, "label-delete-test", runDeleteItemTest(t, "label-delete-test", addDeletableLabel,
func(ctx context.Context, svc *app.Service, appID string) error { func(ctx context.Context, application *models.App) ([]*models.Label, error) {
return svc.AddLabel(ctx, appID, "to.delete", "value") return application.GetLabels(ctx)
}, },
func(ctx context.Context, application *models.App) (int, error) { func(ctx context.Context, svc *app.Service, item *models.Label) error {
labels, err := application.GetLabels(ctx) return svc.DeleteLabel(ctx, item.ID)
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)
}, },
) )
}) })
@@ -497,7 +527,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: "git@example.com:user/repo.git", RepoURL: testRepoURL,
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -547,7 +577,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: "git@example.com:user/repo.git", RepoURL: testRepoURL,
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -583,7 +613,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: "git@example.com:user/repo.git", RepoURL: testRepoURL,
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, models.AppStatusPending, createdApp.Status) assert.Equal(t, models.AppStatusPending, createdApp.Status)
+14 -3
View File
@@ -144,7 +144,11 @@ 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(t, cookie.Secure, "session cookie should have Secure flag in production mode") assert.True(
t,
cookie.Secure,
"session cookie should have Secure flag in production mode",
)
}) })
} }
@@ -324,7 +328,12 @@ 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(t, goroutines-1, failures, "all other goroutines should fail with ErrUserExists") assert.Equal(
t,
goroutines-1,
failures,
"all other goroutines should fail with ErrUserExists",
)
}) })
} }
@@ -380,7 +389,9 @@ func TestDestroySessionMaxAge(testingT *testing.T) {
defer cleanup() defer cleanup()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
err := svc.DestroySession(recorder, request) err := svc.DestroySession(recorder, request)
require.NoError(t, err) require.NoError(t, err)
+66 -14
View File
@@ -66,7 +66,8 @@ 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 in log filenames. // logFileShortSHALength is the number of characters to use for commit SHA
// in log filenames.
const logFileShortSHALength = 12 const logFileShortSHALength = 12
// dockerLogMessage represents a Docker build log message. // dockerLogMessage represents a Docker build log message.
@@ -87,7 +88,10 @@ 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(ctx context.Context, deployment *models.Deployment) *deploymentLogWriter { func newDeploymentLogWriter(
ctx context.Context,
deployment *models.Deployment,
) *deploymentLogWriter {
w := &deploymentLogWriter{ w := &deploymentLogWriter{
deployment: deployment, deployment: deployment,
done: make(chan struct{}), done: make(chan struct{}),
@@ -257,7 +261,10 @@ func (svc *Service) GetBuildDir(appName string) string {
// 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(app *models.App, deployment *models.Deployment) string { func (svc *Service) GetLogFilePath(
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"
@@ -275,7 +282,8 @@ func (svc *Service) GetLogFilePath(app *models.App, deployment *models.Deploymen
// 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 (or appname_timestamp.log.txt if no SHA) // Build filename: appname_sha_timestamp.log.txt
// (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)
@@ -286,6 +294,12 @@ func (svc *Service) GetLogFilePath(app *models.App, deployment *models.Deploymen
return filepath.Join(svc.config.DataDir, "logs", hostname, app.Name, filename) return filepath.Join(svc.config.DataDir, "logs", hostname, app.Name, filename)
} }
// GetLogDir returns the root directory under which all deployment log
// files live. Paths returned by GetLogFilePath are always inside it.
func (svc *Service) GetLogDir() string {
return filepath.Join(svc.config.DataDir, "logs")
}
// HasActiveDeploy returns true if there is an active deployment for the given app. // HasActiveDeploy returns true if there is an active deployment for the given app.
func (svc *Service) HasActiveDeploy(appID string) bool { func (svc *Service) HasActiveDeploy(appID string) bool {
_, ok := svc.activeDeploys.Load(appID) _, ok := svc.activeDeploys.Load(appID)
@@ -308,7 +322,8 @@ 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, ErrDeploymentInProgress is returned. // If cancelExisting is false and a deploy is in progress,
// ErrDeploymentInProgress is returned.
func (svc *Service) Deploy( func (svc *Service) Deploy(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
@@ -342,7 +357,8 @@ 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 regardless of cancellation // Use a background context for DB operations that must complete
// 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)
@@ -401,7 +417,10 @@ 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(ctx, "Rolling back to previous image: "+app.PreviousImageID.String) _ = deployment.AppendLog(
ctx,
"Rolling back to previous image: "+app.PreviousImageID.String,
)
return deployment, nil return deployment, nil
} }
@@ -417,7 +436,11 @@ func (svc *Service) executeRollback(
svc.removeOldContainer(ctx, app, deployment) svc.removeOldContainer(ctx, app, deployment)
rollbackOpts, err := svc.buildContainerOptions(ctx, app, docker.ImageID(previousImageID)) rollbackOpts, err := svc.buildContainerOptions(
ctx,
app,
docker.ImageID(previousImageID),
)
if err != nil { if err != nil {
svc.failDeployment(bgCtx, app, deployment, err) svc.failDeployment(bgCtx, app, deployment, err)
@@ -426,7 +449,12 @@ func (svc *Service) executeRollback(
containerID, err := svc.docker.CreateContainer(ctx, rollbackOpts) containerID, err := svc.docker.CreateContainer(ctx, rollbackOpts)
if err != nil { if err != nil {
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to create rollback container: %w", err)) svc.failDeployment(
bgCtx,
app,
deployment,
fmt.Errorf("failed to create rollback container: %w", err),
)
return fmt.Errorf("failed to create rollback container: %w", err) return fmt.Errorf("failed to create rollback container: %w", err)
} }
@@ -436,7 +464,12 @@ func (svc *Service) executeRollback(
startErr := svc.docker.StartContainer(ctx, containerID) startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil { if startErr != nil {
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to start rollback container: %w", startErr)) svc.failDeployment(
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)
} }
@@ -695,7 +728,11 @@ func (svc *Service) cleanupCancelledDeploy(
if removeErr != nil { if removeErr != nil {
svc.log.Error("failed to remove image from cancelled deploy", svc.log.Error("failed to remove image from cancelled deploy",
"error", removeErr, "app", app.Name, "image", imageID) "error", removeErr, "app", app.Name, "image", imageID)
_ = deployment.AppendLog(ctx, "WARNING: failed to clean up image "+imageID.String()+": "+removeErr.Error()) _ = deployment.AppendLog(
ctx,
"WARNING: failed to clean up image "+
imageID.String()+": "+removeErr.Error(),
)
} else { } else {
svc.log.Info("cleaned up image from cancelled deploy", svc.log.Info("cleaned up image from cancelled deploy",
"app", app.Name, "image", imageID) "app", app.Name, "image", imageID)
@@ -870,14 +907,24 @@ func (svc *Service) cloneRepository(
err := os.MkdirAll(appBuildsDir, buildsDirPermissions) err := os.MkdirAll(appBuildsDir, buildsDirPermissions)
if err != nil { if err != nil {
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create builds dir: %w", err)) svc.failDeployment(
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(ctx, app, deployment, fmt.Errorf("failed to create temp dir: %w", err)) svc.failDeployment(
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)
} }
@@ -908,7 +955,12 @@ func (svc *Service) cloneRepository(
) )
if cloneErr != nil { if cloneErr != nil {
cleanup() cleanup()
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to clone repo: %w", cloneErr)) svc.failDeployment(
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)
} }
@@ -32,7 +32,10 @@ func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) {
require.NoError(t, os.MkdirAll(deployDir, 0o750)) require.NoError(t, os.MkdirAll(deployDir, 0o750))
// Create a file inside to verify full removal // Create a file inside to verify full removal
require.NoError(t, os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o600)) require.NoError(
t,
os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o600),
)
// Also create a dir for a different deployment (should NOT be removed) // Also create a dir for a different deployment (should NOT be removed)
otherDir := filepath.Join(buildDir, "99-xyz789") otherDir := filepath.Join(buildDir, "99-xyz789")
@@ -31,7 +31,9 @@ func TestBuildContainerOptionsUsesImageID(t *testing.T) {
const expectedImageID = docker.ImageID("sha256:abc123def456") const expectedImageID = docker.ImageID("sha256:abc123def456")
opts, err := svc.BuildContainerOptionsExported(context.Background(), app, expectedImageID) opts, err := svc.BuildContainerOptionsExported(
context.Background(), app, expectedImageID,
)
if err != nil { if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err) t.Fatalf("buildContainerOptions returned error: %v", err)
} }
@@ -77,14 +79,20 @@ func TestBuildContainerOptionsNoResourceLimits(t *testing.T) {
} }
} }
func TestBuildContainerOptionsCPULimit(t *testing.T) { // buildOptsForApp saves an app configured by setup and returns the container
t.Parallel() // options built for it.
func buildOptsForApp(
t *testing.T,
name string,
setup func(app *models.App),
) docker.CreateContainerOptions {
t.Helper()
db := database.NewTestDatabase(t) db := database.NewTestDatabase(t)
app := models.NewApp(db) app := models.NewApp(db)
app.Name = "cpulimit" app.Name = name
app.CPULimit = sql.NullFloat64{Float64: 0.5, Valid: true} setup(app)
err := app.Save(context.Background()) err := app.Save(context.Background())
if err != nil { if err != nil {
@@ -101,6 +109,16 @@ func TestBuildContainerOptionsCPULimit(t *testing.T) {
t.Fatalf("buildContainerOptions returned error: %v", err) 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 { if opts.CPULimit != 0.5 {
t.Errorf("expected CPULimit=0.5, got %v", opts.CPULimit) t.Errorf("expected CPULimit=0.5, got %v", opts.CPULimit)
} }
@@ -109,26 +127,9 @@ func TestBuildContainerOptionsCPULimit(t *testing.T) {
func TestBuildContainerOptionsMemoryLimit(t *testing.T) { func TestBuildContainerOptionsMemoryLimit(t *testing.T) {
t.Parallel() t.Parallel()
db := database.NewTestDatabase(t) opts := buildOptsForApp(t, "memlimit", func(app *models.App) {
app := models.NewApp(db)
app.Name = "memlimit"
app.MemoryLimit = sql.NullInt64{Int64: 536870912, Valid: true} // 512m app.MemoryLimit = sql.NullInt64{Int64: 536870912, Valid: true} // 512m
})
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.MemoryLimit != 536870912 { if opts.MemoryLimit != 536870912 {
t.Errorf("expected MemoryLimit=536870912, got %v", opts.MemoryLimit) t.Errorf("expected MemoryLimit=536870912, got %v", opts.MemoryLimit)
+10 -2
View File
@@ -26,7 +26,11 @@ func (svc *Service) CancelActiveDeploy(appID string) {
} }
// RegisterActiveDeploy registers an active deploy for testing. // RegisterActiveDeploy registers an active deploy for testing.
func (svc *Service) RegisterActiveDeploy(appID string, cancel context.CancelFunc, done chan struct{}) { func (svc *Service) RegisterActiveDeploy(
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})
} }
@@ -41,7 +45,11 @@ func (svc *Service) UnlockApp(appID string) {
} }
// NewTestServiceWithConfig creates a Service with config and docker client for testing. // NewTestServiceWithConfig creates a Service with config and docker client for testing.
func NewTestServiceWithConfig(log *slog.Logger, cfg *config.Config, dockerClient *docker.Client) *Service { func NewTestServiceWithConfig(
log *slog.Logger,
cfg *config.Config,
dockerClient *docker.Client,
) *Service {
return &Service{ return &Service{
log: log, log: log,
config: cfg, config: cfg,
+6 -3
View File
@@ -159,7 +159,8 @@ 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) + ": " + deployErr.Error() message := "Deployment failed after " + formatDuration(duration) +
": " + deployErr.Error()
svc.sendNotifications(ctx, app, title, message, message, "error") svc.sendNotifications(ctx, app, title, message, message, "error")
} }
@@ -266,7 +267,8 @@ func (svc *Service) sendNtfy(
request.Header.Set("Title", title) request.Header.Set("Title", title)
request.Header.Set("Priority", svc.ntfyPriority(priority)) request.Header.Set("Priority", svc.ntfyPriority(priority))
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input // #nosec G704 -- URL from validated config, not user input
resp, err := svc.client.Do(request)
if err != nil { if err != nil {
return fmt.Errorf("failed to send ntfy request: %w", err) return fmt.Errorf("failed to send ntfy request: %w", err)
} }
@@ -363,7 +365,8 @@ func (svc *Service) sendSlack(
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input // #nosec G704 -- URL from validated config, not user input
resp, err := svc.client.Do(request)
if err != nil { if err != nil {
return fmt.Errorf("failed to send slack request: %w", err) return fmt.Errorf("failed to send slack request: %w", err)
} }
+49 -60
View File
@@ -98,88 +98,77 @@ type GitLabPushPayload struct {
func ParsePushPayload(source Source, payload []byte) (*PushEvent, error) { func ParsePushPayload(source Source, payload []byte) (*PushEvent, error) {
switch source { switch source {
case SourceGitHub: case SourceGitHub:
return parseGitHubPush(payload) return parsePush(payload, githubPushEvent)
case SourceGitLab: case SourceGitLab:
return parseGitLabPush(payload) return parsePush(payload, gitlabPushEvent)
case SourceGitea, SourceUnknown: case SourceGitea, SourceUnknown:
// Gitea and unknown both use Gitea format for backward compatibility. // Gitea and unknown both use Gitea format for backward compatibility.
return parseGiteaPush(payload) return parsePush(payload, giteaPushEvent)
} }
// Unreachable for known source values, but satisfies exhaustive checker. // Unreachable for known source values, but satisfies exhaustive checker.
return parseGiteaPush(payload) return parsePush(payload, giteaPushEvent)
} }
func parseGiteaPush(payload []byte) (*PushEvent, error) { // parsePush unmarshals payload into P and converts it into a normalized
var p GiteaPushPayload // PushEvent via build.
func parsePush[P any](payload []byte, build func(P) *PushEvent) (*PushEvent, error) {
var p P
unmarshalErr := json.Unmarshal(payload, &p) unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil { if unmarshalErr != nil {
return nil, unmarshalErr return nil, unmarshalErr
} }
commitURL := extractGiteaCommitURL(p) return build(p), nil
return &PushEvent{
Source: SourceGitea,
Ref: p.Ref,
Before: p.Before,
After: p.After,
Branch: extractBranch(p.Ref),
RepoName: p.Repository.FullName,
CloneURL: p.Repository.CloneURL,
HTMLURL: p.Repository.HTMLURL,
CommitURL: commitURL,
Pusher: p.Pusher.Username,
}, nil
} }
func parseGitHubPush(payload []byte) (*PushEvent, error) { // basePushEvent builds a PushEvent populated with the fields shared by all
var p GitHubPushPayload // webhook sources.
func basePushEvent(source Source, ref, before, after string) *PushEvent {
unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil {
return nil, unmarshalErr
}
commitURL := extractGitHubCommitURL(p)
return &PushEvent{ return &PushEvent{
Source: SourceGitHub, Source: source,
Ref: p.Ref, Ref: ref,
Before: p.Before, Before: before,
After: p.After, After: after,
Branch: extractBranch(p.Ref), Branch: extractBranch(ref),
RepoName: p.Repository.FullName, }
CloneURL: p.Repository.CloneURL,
HTMLURL: p.Repository.HTMLURL,
CommitURL: commitURL,
Pusher: p.Pusher.Name,
}, nil
} }
func parseGitLabPush(payload []byte) (*PushEvent, error) { // giteaPushEvent converts a Gitea push payload to a normalized PushEvent.
var p GitLabPushPayload 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
unmarshalErr := json.Unmarshal(payload, &p) return event
if unmarshalErr != nil { }
return nil, unmarshalErr
}
commitURL := extractGitLabCommitURL(p) // 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 &PushEvent{ return event
Source: SourceGitLab, }
Ref: p.Ref,
Before: p.Before, // githubPushEvent converts a GitHub push payload to a normalized PushEvent.
After: p.After, func githubPushEvent(p GitHubPushPayload) *PushEvent {
Branch: extractBranch(p.Ref), event := basePushEvent(SourceGitHub, p.Ref, p.Before, p.After)
RepoName: p.Project.PathWithNamespace, event.RepoName = p.Repository.FullName
CloneURL: p.Project.GitHTTPURL, event.CloneURL = p.Repository.CloneURL
HTMLURL: p.Project.WebURL, event.HTMLURL = p.Repository.HTMLURL
CommitURL: commitURL, event.CommitURL = extractGitHubCommitURL(p)
Pusher: p.UserName, event.Pusher = p.Pusher.Name
}, nil
return event
} }
// extractBranch extracts the branch name from a git ref. // extractBranch extracts the branch name from a git ref.
+15 -2
View File
@@ -6,6 +6,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
"sync"
"go.uber.org/fx" "go.uber.org/fx"
@@ -31,6 +32,10 @@ type Service struct {
db *database.Database db *database.Database
deploy *deploy.Service deploy *deploy.Service
params *ServiceParams params *ServiceParams
// deployments tracks the deployment goroutines started by
// triggerDeployment so callers can wait for them to finish.
deployments sync.WaitGroup
} }
// New creates a new webhook Service. // New creates a new webhook Service.
@@ -108,6 +113,14 @@ func (svc *Service) HandleWebhook(
return nil return nil
} }
// WaitForDeployments blocks until every deployment goroutine started by
// HandleWebhook has finished, including all writes under the data
// directory. It exists so callers and tests can synchronize on async
// deployment completion instead of polling or sleeping.
func (svc *Service) WaitForDeployments() {
svc.deployments.Wait()
}
func (svc *Service) triggerDeployment( func (svc *Service) triggerDeployment(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
@@ -117,7 +130,7 @@ func (svc *Service) triggerDeployment(
eventID := event.ID eventID := event.ID
appName := app.Name appName := app.Name
go func() { svc.deployments.Go(func() {
// Use context.WithoutCancel to ensure deployment completes // Use context.WithoutCancel to ensure deployment completes
// even if the HTTP request context is cancelled. // even if the HTTP request context is cancelled.
deployCtx := context.WithoutCancel(ctx) deployCtx := context.WithoutCancel(ctx)
@@ -130,5 +143,5 @@ func (svc *Service) triggerDeployment(
// Mark event as processed // Mark event as processed
event.Processed = true event.Processed = true
_ = event.Save(deployCtx) _ = event.Save(deployCtx)
}() })
} }
+257 -160
View File
@@ -7,7 +7,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -24,6 +23,18 @@ import (
"sneak.berlin/go/upaas/internal/service/webhook" "sneak.berlin/go/upaas/internal/service/webhook"
) )
const (
giteaEventHeader = "X-Gitea-Event"
githubEventHeader = "X-GitHub-Event"
gitlabEventHeader = "X-Gitlab-Event"
gitlabPushHook = "Push Hook"
pushEventType = "push"
branchMain = "main"
refMain = "refs/heads/main"
testCommitSHA = "abc123def456789"
testPusher = "developer"
)
type testDeps struct { type testDeps struct {
logger *logger.Logger logger *logger.Logger
config *config.Config config *config.Config
@@ -45,9 +56,14 @@ func setupTestDeps(t *testing.T) *testDeps {
loggerInst, err := logger.New(fx.Lifecycle(nil), logger.Params{Globals: globalsInst}) loggerInst, err := logger.New(fx.Lifecycle(nil), logger.Params{Globals: globalsInst})
require.NoError(t, err) require.NoError(t, err)
cfg := &config.Config{Port: 8080, DataDir: tmpDir, SessionSecret: "test-secret-key-at-least-32-chars"} cfg := &config.Config{
Port: 8080, DataDir: tmpDir,
SessionSecret: "test-secret-key-at-least-32-chars",
}
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{Logger: loggerInst, Config: cfg}) dbInst, err := database.New(
fx.Lifecycle(nil), database.Params{Logger: loggerInst, Config: cfg},
)
require.NoError(t, err) require.NoError(t, err)
return &testDeps{logger: loggerInst, config: cfg, db: dbInst, tmpDir: tmpDir} return &testDeps{logger: loggerInst, config: cfg, db: dbInst, tmpDir: tmpDir}
@@ -58,14 +74,19 @@ func setupTestService(t *testing.T) (*webhook.Service, *database.Database, func(
deps := setupTestDeps(t) deps := setupTestDeps(t)
dockerClient, err := docker.New(fx.Lifecycle(nil), docker.Params{Logger: deps.logger, Config: deps.config}) dockerClient, err := docker.New(
fx.Lifecycle(nil), docker.Params{Logger: deps.logger, Config: deps.config},
)
require.NoError(t, err) require.NoError(t, err)
notifySvc, err := notify.New(fx.Lifecycle(nil), notify.ServiceParams{Logger: deps.logger}) notifySvc, err := notify.New(
fx.Lifecycle(nil), notify.ServiceParams{Logger: deps.logger},
)
require.NoError(t, err) require.NoError(t, err)
deploySvc, err := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{ deploySvc, err := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{
Logger: deps.logger, Config: deps.config, Database: deps.db, Docker: dockerClient, Notify: notifySvc, Logger: deps.logger, Config: deps.config, Database: deps.db,
Docker: dockerClient, Notify: notifySvc,
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -104,8 +125,6 @@ func createTestApp(
} }
// TestDetectWebhookSource tests auto-detection of webhook source from HTTP headers. // TestDetectWebhookSource tests auto-detection of webhook source from HTTP headers.
//
//nolint:funlen // table-driven test with comprehensive test cases
func TestDetectWebhookSource(testingT *testing.T) { func TestDetectWebhookSource(testingT *testing.T) {
testingT.Parallel() testingT.Parallel()
@@ -116,17 +135,17 @@ func TestDetectWebhookSource(testingT *testing.T) {
}{ }{
{ {
name: "detects Gitea from X-Gitea-Event header", name: "detects Gitea from X-Gitea-Event header",
headers: map[string]string{"X-Gitea-Event": "push"}, headers: map[string]string{giteaEventHeader: pushEventType},
expected: webhook.SourceGitea, expected: webhook.SourceGitea,
}, },
{ {
name: "detects GitHub from X-GitHub-Event header", name: "detects GitHub from X-GitHub-Event header",
headers: map[string]string{"X-GitHub-Event": "push"}, headers: map[string]string{githubEventHeader: pushEventType},
expected: webhook.SourceGitHub, expected: webhook.SourceGitHub,
}, },
{ {
name: "detects GitLab from X-Gitlab-Event header", name: "detects GitLab from X-Gitlab-Event header",
headers: map[string]string{"X-Gitlab-Event": "Push Hook"}, headers: map[string]string{gitlabEventHeader: gitlabPushHook},
expected: webhook.SourceGitLab, expected: webhook.SourceGitLab,
}, },
{ {
@@ -142,16 +161,16 @@ func TestDetectWebhookSource(testingT *testing.T) {
{ {
name: "Gitea takes precedence over GitHub", name: "Gitea takes precedence over GitHub",
headers: map[string]string{ headers: map[string]string{
"X-Gitea-Event": "push", giteaEventHeader: pushEventType,
"X-GitHub-Event": "push", githubEventHeader: pushEventType,
}, },
expected: webhook.SourceGitea, expected: webhook.SourceGitea,
}, },
{ {
name: "GitHub takes precedence over GitLab", name: "GitHub takes precedence over GitLab",
headers: map[string]string{ headers: map[string]string{
"X-GitHub-Event": "push", githubEventHeader: pushEventType,
"X-Gitlab-Event": "Push Hook", gitlabEventHeader: gitlabPushHook,
}, },
expected: webhook.SourceGitHub, expected: webhook.SourceGitHub,
}, },
@@ -184,33 +203,33 @@ func TestDetectEventType(testingT *testing.T) {
}{ }{
{ {
name: "extracts Gitea event type", name: "extracts Gitea event type",
headers: map[string]string{"X-Gitea-Event": "push"}, headers: map[string]string{giteaEventHeader: pushEventType},
source: webhook.SourceGitea, source: webhook.SourceGitea,
expected: "push", expected: pushEventType,
}, },
{ {
name: "extracts GitHub event type", name: "extracts GitHub event type",
headers: map[string]string{"X-GitHub-Event": "push"}, headers: map[string]string{githubEventHeader: pushEventType},
source: webhook.SourceGitHub, source: webhook.SourceGitHub,
expected: "push", expected: pushEventType,
}, },
{ {
name: "extracts GitLab event type", name: "extracts GitLab event type",
headers: map[string]string{"X-Gitlab-Event": "Push Hook"}, headers: map[string]string{gitlabEventHeader: gitlabPushHook},
source: webhook.SourceGitLab, source: webhook.SourceGitLab,
expected: "Push Hook", expected: gitlabPushHook,
}, },
{ {
name: "returns push for unknown source", name: "returns push for unknown source",
headers: map[string]string{}, headers: map[string]string{},
source: webhook.SourceUnknown, source: webhook.SourceUnknown,
expected: "push", expected: pushEventType,
}, },
{ {
name: "returns push when header missing for source", name: "returns push when header missing for source",
headers: map[string]string{}, headers: map[string]string{},
source: webhook.SourceGitea, source: webhook.SourceGitea,
expected: "push", expected: pushEventType,
}, },
} }
@@ -250,11 +269,54 @@ func TestUnparsedURLString(t *testing.T) {
assert.Empty(t, empty.String()) assert.Empty(t, empty.String())
} }
// TestParsePushPayloadGitea tests parsing of Gitea push payloads. // pushEventExpectation describes the expected normalized fields of a parsed
func TestParsePushPayloadGitea(t *testing.T) { // push payload.
t.Parallel() type pushEventExpectation struct {
source webhook.Source
ref string
branch string
after string
repoName string
cloneURL webhook.UnparsedURL
htmlURL webhook.UnparsedURL
commitURL webhook.UnparsedURL
pusher string
}
payload := []byte(`{ // assertPushEvent parses payload for want.source and asserts every
// normalized PushEvent field matches want.
func assertPushEvent(t *testing.T, payload []byte, want pushEventExpectation) {
t.Helper()
event, err := webhook.ParsePushPayload(want.source, payload)
require.NoError(t, err)
assert.Equal(t, want.source, event.Source)
assert.Equal(t, want.ref, event.Ref)
assert.Equal(t, want.branch, event.Branch)
assert.Equal(t, want.after, event.After)
assertPushEventOrigin(t, event, want)
}
// assertPushEventOrigin asserts the repository and pusher fields of event.
func assertPushEventOrigin(
t *testing.T,
event *webhook.PushEvent,
want pushEventExpectation,
) {
t.Helper()
assert.Equal(t, want.repoName, event.RepoName)
assert.Equal(t, want.cloneURL, event.CloneURL)
assert.Equal(t, want.htmlURL, event.HTMLURL)
assert.Equal(t, want.commitURL, event.CommitURL)
assert.Equal(t, want.pusher, event.Pusher)
}
// giteaPushJSON returns a realistic Gitea push webhook payload.
func giteaPushJSON() []byte {
return []byte(`{
"ref": "refs/heads/main", "ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000", "before": "0000000000000000000000000000000000000000",
"after": "abc123def456789", "after": "abc123def456789",
@@ -275,29 +337,11 @@ func TestParsePushPayloadGitea(t *testing.T) {
} }
] ]
}`) }`)
event, err := webhook.ParsePushPayload(webhook.SourceGitea, payload)
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitea, event.Source)
assert.Equal(t, "refs/heads/main", event.Ref)
assert.Equal(t, "main", event.Branch)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "myorg/myrepo", event.RepoName)
assert.Equal(t, webhook.UnparsedURL("https://gitea.example.com/myorg/myrepo.git"), event.CloneURL)
assert.Equal(t, webhook.UnparsedURL("https://gitea.example.com/myorg/myrepo"), event.HTMLURL)
assert.Equal(t,
webhook.UnparsedURL("https://gitea.example.com/myorg/myrepo/commit/abc123def456789"),
event.CommitURL,
)
assert.Equal(t, "developer", event.Pusher)
} }
// TestParsePushPayloadGitHub tests parsing of GitHub push payloads. // githubPushJSON returns a realistic GitHub push webhook payload.
func TestParsePushPayloadGitHub(t *testing.T) { func githubPushJSON() []byte {
t.Parallel() return []byte(`{
payload := []byte(`{
"ref": "refs/heads/main", "ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000", "before": "0000000000000000000000000000000000000000",
"after": "abc123def456789", "after": "abc123def456789",
@@ -323,29 +367,11 @@ func TestParsePushPayloadGitHub(t *testing.T) {
} }
] ]
}`) }`)
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitHub, event.Source)
assert.Equal(t, "refs/heads/main", event.Ref)
assert.Equal(t, "main", event.Branch)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "myorg/myrepo", event.RepoName)
assert.Equal(t, webhook.UnparsedURL("https://github.com/myorg/myrepo.git"), event.CloneURL)
assert.Equal(t, webhook.UnparsedURL("https://github.com/myorg/myrepo"), event.HTMLURL)
assert.Equal(t,
webhook.UnparsedURL("https://github.com/myorg/myrepo/commit/abc123def456789"),
event.CommitURL,
)
assert.Equal(t, "developer", event.Pusher)
} }
// TestParsePushPayloadGitLab tests parsing of GitLab push payloads. // gitlabPushJSON returns a realistic GitLab push webhook payload.
func TestParsePushPayloadGitLab(t *testing.T) { func gitlabPushJSON() []byte {
t.Parallel() return []byte(`{
payload := []byte(`{
"ref": "refs/heads/develop", "ref": "refs/heads/develop",
"before": "0000000000000000000000000000000000000000", "before": "0000000000000000000000000000000000000000",
"after": "abc123def456789", "after": "abc123def456789",
@@ -366,25 +392,78 @@ func TestParsePushPayloadGitLab(t *testing.T) {
} }
] ]
}`) }`)
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload)
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitLab, event.Source)
assert.Equal(t, "refs/heads/develop", event.Ref)
assert.Equal(t, "develop", event.Branch)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "mygroup/myproject", event.RepoName)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/mygroup/myproject.git"), event.CloneURL)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/mygroup/myproject"), event.HTMLURL)
assert.Equal(t,
webhook.UnparsedURL("https://gitlab.com/mygroup/myproject/-/commit/abc123def456789"),
event.CommitURL,
)
assert.Equal(t, "developer", event.Pusher)
} }
// TestParsePushPayloadUnknownFallsBackToGitea tests that unknown source uses Gitea parser. // pushPayloadJSON returns the push payload fixture for source.
func pushPayloadJSON(t *testing.T, source webhook.Source) []byte {
t.Helper()
switch source {
case webhook.SourceGitHub:
return githubPushJSON()
case webhook.SourceGitLab:
return gitlabPushJSON()
case webhook.SourceGitea, webhook.SourceUnknown:
return giteaPushJSON()
}
t.Fatalf("no push payload fixture for source %v", source)
return nil
}
// TestParsePushPayload tests parsing of Gitea, GitHub, and GitLab push
// payloads into normalized PushEvents.
func TestParsePushPayload(testingT *testing.T) {
testingT.Parallel()
tests := []pushEventExpectation{
{
source: webhook.SourceGitea,
ref: refMain,
branch: branchMain,
after: testCommitSHA,
repoName: "myorg/myrepo",
cloneURL: "https://gitea.example.com/myorg/myrepo.git",
htmlURL: "https://gitea.example.com/myorg/myrepo",
commitURL: "https://gitea.example.com/myorg/myrepo/commit/abc123def456789",
pusher: testPusher,
},
{
source: webhook.SourceGitHub,
ref: refMain,
branch: branchMain,
after: testCommitSHA,
repoName: "myorg/myrepo",
cloneURL: "https://github.com/myorg/myrepo.git",
htmlURL: "https://github.com/myorg/myrepo",
commitURL: "https://github.com/myorg/myrepo/commit/abc123def456789",
pusher: testPusher,
},
{
source: webhook.SourceGitLab,
ref: "refs/heads/develop",
branch: "develop",
after: testCommitSHA,
repoName: "mygroup/myproject",
cloneURL: "https://gitlab.com/mygroup/myproject.git",
htmlURL: "https://gitlab.com/mygroup/myproject",
commitURL: "https://gitlab.com/mygroup/myproject/-/commit/abc123def456789",
pusher: testPusher,
},
}
for _, testCase := range tests {
testingT.Run(testCase.source.String(), func(t *testing.T) {
t.Parallel()
assertPushEvent(t, pushPayloadJSON(t, testCase.source), testCase)
})
}
}
// TestParsePushPayloadUnknownFallsBackToGitea tests that unknown source
// uses the Gitea parser.
func TestParsePushPayloadUnknownFallsBackToGitea(t *testing.T) { func TestParsePushPayloadUnknownFallsBackToGitea(t *testing.T) {
t.Parallel() t.Parallel()
@@ -399,7 +478,7 @@ func TestParsePushPayloadUnknownFallsBackToGitea(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, webhook.SourceGitea, event.Source) assert.Equal(t, webhook.SourceGitea, event.Source)
assert.Equal(t, "main", event.Branch) assert.Equal(t, branchMain, event.Branch)
assert.Equal(t, "abc123", event.After) assert.Equal(t, "abc123", event.After)
} }
@@ -462,7 +541,10 @@ func TestGitHubCommitURLFallback(t *testing.T) {
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload) event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL) assert.Equal(t,
webhook.UnparsedURL("https://github.com/u/r/commit/abc123"),
event.CommitURL,
)
}) })
t.Run("falls back to commits list", func(t *testing.T) { t.Run("falls back to commits list", func(t *testing.T) {
@@ -477,7 +559,10 @@ func TestGitHubCommitURLFallback(t *testing.T) {
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload) event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL) assert.Equal(t,
webhook.UnparsedURL("https://github.com/u/r/commit/abc123"),
event.CommitURL,
)
}) })
t.Run("constructs URL from repo HTML URL", func(t *testing.T) { t.Run("constructs URL from repo HTML URL", func(t *testing.T) {
@@ -491,7 +576,10 @@ func TestGitHubCommitURLFallback(t *testing.T) {
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload) event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL) assert.Equal(t,
webhook.UnparsedURL("https://github.com/u/r/commit/abc123"),
event.CommitURL,
)
}) })
} }
@@ -511,7 +599,10 @@ func TestGitLabCommitURLFallback(t *testing.T) {
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload) event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/g/p/-/commit/abc123"), event.CommitURL) assert.Equal(t,
webhook.UnparsedURL("https://gitlab.com/g/p/-/commit/abc123"),
event.CommitURL,
)
}) })
t.Run("constructs URL from project web URL", func(t *testing.T) { t.Run("constructs URL from project web URL", func(t *testing.T) {
@@ -525,7 +616,10 @@ func TestGitLabCommitURLFallback(t *testing.T) {
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload) event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/g/p/-/commit/abc123"), event.CommitURL) assert.Equal(t,
webhook.UnparsedURL("https://gitlab.com/g/p/-/commit/abc123"),
event.CommitURL,
)
}) })
} }
@@ -588,7 +682,8 @@ func TestGiteaPushPayloadParsing(testingT *testing.T) {
}) })
} }
// TestGitHubPushPayloadParsing tests direct deserialization of the GitHub payload struct. // TestGitHubPushPayloadParsing tests deserialization of the GitHub payload
// struct.
func TestGitHubPushPayloadParsing(t *testing.T) { func TestGitHubPushPayloadParsing(t *testing.T) {
t.Parallel() t.Parallel()
@@ -633,7 +728,8 @@ func TestGitHubPushPayloadParsing(t *testing.T) {
assert.Len(t, p.Commits, 1) assert.Len(t, p.Commits, 1)
} }
// TestGitLabPushPayloadParsing tests direct deserialization of the GitLab payload struct. // TestGitLabPushPayloadParsing tests deserialization of the GitLab payload
// struct.
func TestGitLabPushPayloadParsing(t *testing.T) { func TestGitLabPushPayloadParsing(t *testing.T) {
t.Parallel() t.Parallel()
@@ -671,9 +767,8 @@ func TestGitLabPushPayloadParsing(t *testing.T) {
assert.Len(t, p.Commits, 1) assert.Len(t, p.Commits, 1)
} }
// TestExtractBranch tests branch extraction via HandleWebhook integration (extractBranch is unexported). // TestExtractBranch tests branch extraction via HandleWebhook integration
// // (extractBranch is unexported).
//nolint:funlen // table-driven test with comprehensive test cases
func TestExtractBranch(testingT *testing.T) { func TestExtractBranch(testingT *testing.T) {
testingT.Parallel() testingT.Parallel()
@@ -684,8 +779,8 @@ func TestExtractBranch(testingT *testing.T) {
}{ }{
{ {
name: "extracts main branch", name: "extracts main branch",
ref: "refs/heads/main", ref: refMain,
expected: "main", expected: branchMain,
}, },
{ {
name: "extracts feature branch", name: "extracts feature branch",
@@ -699,8 +794,8 @@ func TestExtractBranch(testingT *testing.T) {
}, },
{ {
name: "returns raw ref if no prefix", name: "returns raw ref if no prefix",
ref: "main", ref: branchMain,
expected: "main", expected: branchMain,
}, },
{ {
name: "handles empty ref", name: "handles empty ref",
@@ -728,12 +823,13 @@ func TestExtractBranch(testingT *testing.T) {
payload := []byte(`{"ref": "` + testCase.ref + `"}`) payload := []byte(`{"ref": "` + testCase.ref + `"}`)
err := svc.HandleWebhook( err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, "push", payload, context.Background(), app, webhook.SourceGitea, pushEventType, payload,
) )
require.NoError(t, err) require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup // Wait for the async deployment goroutine to finish so its
time.Sleep(100 * time.Millisecond) // writes under the temp dir complete before test cleanup.
svc.WaitForDeployments()
events, err := app.GetWebhookEvents(context.Background(), 10) events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err) require.NoError(t, err)
@@ -750,7 +846,7 @@ func TestHandleWebhookMatchingBranch(t *testing.T) {
svc, dbInst, cleanup := setupTestService(t) svc, dbInst, cleanup := setupTestService(t)
defer cleanup() defer cleanup()
app := createTestApp(t, dbInst, "main") app := createTestApp(t, dbInst, branchMain)
payload := []byte(`{ payload := []byte(`{
"ref": "refs/heads/main", "ref": "refs/heads/main",
@@ -767,20 +863,21 @@ func TestHandleWebhookMatchingBranch(t *testing.T) {
}`) }`)
err := svc.HandleWebhook( err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, "push", payload, context.Background(), app, webhook.SourceGitea, pushEventType, payload,
) )
require.NoError(t, err) require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup // Wait for the async deployment goroutine to finish so its writes
time.Sleep(100 * time.Millisecond) // under the temp dir complete before test cleanup.
svc.WaitForDeployments()
events, err := app.GetWebhookEvents(context.Background(), 10) events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, events, 1) require.Len(t, events, 1)
event := events[0] event := events[0]
assert.Equal(t, "push", event.EventType) assert.Equal(t, pushEventType, event.EventType)
assert.Equal(t, "main", event.Branch) assert.Equal(t, branchMain, event.Branch)
assert.True(t, event.Matched) assert.True(t, event.Matched)
assert.Equal(t, "abc123def456", event.CommitSHA.String) assert.Equal(t, "abc123def456", event.CommitSHA.String)
} }
@@ -791,12 +888,12 @@ func TestHandleWebhookNonMatchingBranch(t *testing.T) {
svc, dbInst, cleanup := setupTestService(t) svc, dbInst, cleanup := setupTestService(t)
defer cleanup() defer cleanup()
app := createTestApp(t, dbInst, "main") app := createTestApp(t, dbInst, branchMain)
payload := []byte(`{"ref": "refs/heads/develop", "after": "def789ghi012"}`) payload := []byte(`{"ref": "refs/heads/develop", "after": "def789ghi012"}`)
err := svc.HandleWebhook( err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, "push", payload, context.Background(), app, webhook.SourceGitea, pushEventType, payload,
) )
require.NoError(t, err) require.NoError(t, err)
@@ -814,10 +911,11 @@ func TestHandleWebhookInvalidJSON(t *testing.T) {
svc, dbInst, cleanup := setupTestService(t) svc, dbInst, cleanup := setupTestService(t)
defer cleanup() defer cleanup()
app := createTestApp(t, dbInst, "main") app := createTestApp(t, dbInst, branchMain)
err := svc.HandleWebhook( err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, "push", []byte(`{invalid json}`), context.Background(), app, webhook.SourceGitea, pushEventType,
[]byte(`{invalid json}`),
) )
require.NoError(t, err) require.NoError(t, err)
@@ -832,10 +930,10 @@ func TestHandleWebhookEmptyPayload(t *testing.T) {
svc, dbInst, cleanup := setupTestService(t) svc, dbInst, cleanup := setupTestService(t)
defer cleanup() defer cleanup()
app := createTestApp(t, dbInst, "main") app := createTestApp(t, dbInst, branchMain)
err := svc.HandleWebhook( err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, "push", []byte(`{}`), context.Background(), app, webhook.SourceGitea, pushEventType, []byte(`{}`),
) )
require.NoError(t, err) require.NoError(t, err)
@@ -845,14 +943,44 @@ func TestHandleWebhookEmptyPayload(t *testing.T) {
assert.False(t, events[0].Matched) assert.False(t, events[0].Matched)
} }
// TestHandleWebhookGitHubSource tests HandleWebhook with a GitHub push payload. // assertHandleWebhookDeploys runs HandleWebhook for payload against a fresh
func TestHandleWebhookGitHubSource(t *testing.T) { // app on branchMain and asserts the recorded event matched with the given
t.Parallel() // commit SHA and commit URL.
func assertHandleWebhookDeploys(
t *testing.T,
source webhook.Source,
payload []byte,
wantSHA string,
wantCommitURL string,
) {
t.Helper()
svc, dbInst, cleanup := setupTestService(t) svc, dbInst, cleanup := setupTestService(t)
defer cleanup() defer cleanup()
app := createTestApp(t, dbInst, "main") app := createTestApp(t, dbInst, branchMain)
err := svc.HandleWebhook(context.Background(), app, source, pushEventType, payload)
require.NoError(t, err)
// Wait for the async deployment goroutine to finish so its writes
// under the temp dir complete before test cleanup.
svc.WaitForDeployments()
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, branchMain, event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, wantSHA, event.CommitSHA.String)
assert.Equal(t, wantCommitURL, event.CommitURL.String)
}
// TestHandleWebhookGitHubSource tests HandleWebhook with a GitHub push payload.
func TestHandleWebhookGitHubSource(t *testing.T) {
t.Parallel()
payload := []byte(`{ payload := []byte(`{
"ref": "refs/heads/main", "ref": "refs/heads/main",
@@ -870,34 +998,16 @@ func TestHandleWebhookGitHubSource(t *testing.T) {
} }
}`) }`)
err := svc.HandleWebhook( assertHandleWebhookDeploys(
context.Background(), app, webhook.SourceGitHub, "push", payload, t, webhook.SourceGitHub, payload,
"github123", "https://github.com/org/repo/commit/github123",
) )
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, "main", event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, "github123", event.CommitSHA.String)
assert.Equal(t, "https://github.com/org/repo/commit/github123", event.CommitURL.String)
} }
// TestHandleWebhookGitLabSource tests HandleWebhook with a GitLab push payload. // TestHandleWebhookGitLabSource tests HandleWebhook with a GitLab push payload.
func TestHandleWebhookGitLabSource(t *testing.T) { func TestHandleWebhookGitLabSource(t *testing.T) {
t.Parallel() t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{ payload := []byte(`{
"ref": "refs/heads/main", "ref": "refs/heads/main",
"after": "gitlab456", "after": "gitlab456",
@@ -917,23 +1027,10 @@ func TestHandleWebhookGitLabSource(t *testing.T) {
] ]
}`) }`)
err := svc.HandleWebhook( assertHandleWebhookDeploys(
context.Background(), app, webhook.SourceGitLab, "push", payload, t, webhook.SourceGitLab, payload,
"gitlab456", "https://gitlab.com/group/project/-/commit/gitlab456",
) )
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, "main", event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, "gitlab456", event.CommitSHA.String)
assert.Equal(t, "https://gitlab.com/group/project/-/commit/gitlab456", event.CommitURL.String)
} }
// TestSetupTestService verifies the test helper creates a working test service. // TestSetupTestService verifies the test helper creates a working test service.
@@ -962,10 +1059,10 @@ func TestPushEventConstruction(t *testing.T) {
event := webhook.PushEvent{ event := webhook.PushEvent{
Source: webhook.SourceGitHub, Source: webhook.SourceGitHub,
Ref: "refs/heads/main", Ref: refMain,
Before: "000", Before: "000",
After: "abc", After: "abc",
Branch: "main", Branch: branchMain,
RepoName: "org/repo", RepoName: "org/repo",
CloneURL: webhook.UnparsedURL("https://github.com/org/repo.git"), CloneURL: webhook.UnparsedURL("https://github.com/org/repo.git"),
HTMLURL: webhook.UnparsedURL("https://github.com/org/repo"), HTMLURL: webhook.UnparsedURL("https://github.com/org/repo"),
@@ -973,7 +1070,7 @@ func TestPushEventConstruction(t *testing.T) {
Pusher: "user", Pusher: "user",
} }
assert.Equal(t, "main", event.Branch) assert.Equal(t, branchMain, event.Branch)
assert.Equal(t, webhook.SourceGitHub, event.Source) assert.Equal(t, webhook.SourceGitHub, event.Source)
assert.Equal(t, "abc", event.After) assert.Equal(t, "abc", event.After)
} }
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"prettier": "3.8.1"
}
}
+88 -35
View File
@@ -3,18 +3,28 @@
# this repo. Idempotent: every install is guarded by a check so already # this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes NOTHING is present (not git, # 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 # make, or go). goimports is installed with `go install` at a pinned
# it is installed from a hash-verified GitHub release archive (never # version (integrity via the Go module checksum database) into
# curl | sh). # /usr/local/bin so it is on PATH. Node is used directly if installed;
# otherwise it is installed at a pinned version via nvm (installing nvm
# itself first, from a hash-verified release archive, never curl | sh),
# then the pinned prettier from yarn.lock. The linter is not installed
# here: it runs only in Docker via script/lint, so docker is its sole
# prerequisite.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-07. Never "latest"; exact versions only. # Pinned versions. Never "latest"; exact versions only.
GOLANGCI_LINT_VERSION="2.10.1" # golang.org/x/tools goimports, 2026-08-13. v0.49.0 requires Go 1.25 (matches
# sha256 of golangci-lint-2.10.1-linux-<arch>.tar.gz release archives # go.mod); v0.50.0 needs Go 1.26. Integrity via the Go module checksum database.
GOLANGCI_LINT_SHA256_AMD64="dfa775874cf0561b404a02a8f4481fc69b28091da95aa697259820d429b09c99" GOIMPORTS_VERSION="v0.49.0"
GOLANGCI_LINT_SHA256_ARM64="6652b42ae02915eb2f9cb2a2e0cac99514c8eded8388d88ae3e06e1a52c00de8" # Node/yarn toolchain, 2026-07-06.
NODE_VERSION="22.17.0"
NVM_VERSION="0.40.3"
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
YARN_VERSION="1.22.22"
PKGMGR="" PKGMGR=""
SUDO="" SUDO=""
@@ -71,35 +81,65 @@ verify_sha256() {
fi fi
} }
# apt has no golangci-lint package: install a pinned release archive # goimports is not packaged uniformly across nix/apt/brew/apk, so install it
# from GitHub, verified by hardcoded sha256 (never curl | sh). # with `go install` at a pinned version and place the binary in /usr/local/bin
install_golangci_lint_release() { # so it is on PATH regardless of shell config. Requires go, which main
case "$(uname -m)" in # installs first.
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;; ensure_goimports() {
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;; if ! missing goimports; then return 0; fi
*) detect_pkgmgr
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)" tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/$name.tar.gz" \ GOBIN="$tmp" go install "golang.org/x/tools/cmd/goimports@${GOIMPORTS_VERSION}"
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz" $SUDO install -m 0755 "$tmp/goimports" /usr/local/bin/goimports
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" rm -rf "$tmp"
} }
ensure_golangci_lint() { # nvm is a bash script; run a command in a bash with nvm loaded
if ! missing golangci-lint; then return 0; fi nvm_sh() {
detect_pkgmgr bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
case "$PKGMGR" in }
apt) install_golangci_lint_release ;;
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;; ensure_nvm() {
esac [ -s "$HOME/.nvm/nvm.sh" ] && return 0
# nvm prerequisites; nvm itself requires bash
if missing bash; then pkg_install bash bash bash bash; fi
if missing curl; then pkg_install curl curl curl curl; fi
if missing git; then pkg_install git git git git; fi
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/nvm.tar.gz" \
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
mkdir -p "$HOME/.nvm"
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
rm -rf "$tmp"
}
ensure_node() {
if ! missing node; then return 0; fi
ensure_nvm
nvm_sh "nvm install $NODE_VERSION"
}
ensure_yarn() {
if ! missing yarn; then return 0; fi
if ! missing corepack; then
corepack enable
corepack prepare "yarn@$YARN_VERSION" --activate
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
corepack prepare yarn@$YARN_VERSION --activate"
else
npm install -g "yarn@$YARN_VERSION"
fi
}
install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
yarn install --frozen-lockfile"
else
yarn install --frozen-lockfile
fi
} }
main() { main() {
@@ -109,9 +149,22 @@ main() {
if missing git; then pkg_install git git git git; fi if missing git; then pkg_install git git git git; fi
if missing make; then pkg_install gnumake make make make; fi if missing make; then pkg_install gnumake make make make; fi
# Go toolchain and linter # Go toolchain
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
ensure_golangci_lint ensure_goimports
# Node toolchain and pinned prettier
ensure_node
ensure_yarn
install_js_deps
# The linter runs only in Docker (script/lint). Warn, don't fail: the
# rest of the repo works without it.
if missing docker; then
echo "bootstrap: WARNING: docker not found; make lint and" >&2
echo "bootstrap: make check require it. Install docker to run" >&2
echo "bootstrap: the linter." >&2
fi
go mod download go mod download
+24 -1
View File
@@ -4,11 +4,34 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
yarn "$@"
return
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
gofmt -s -w . gofmt -s -w .
goimports -w . goimports -w .
npx prettier --write --tab-width 4 static/js/*.js # Pinned prettier reads settings from .prettierrc (tabWidth 4,
# proseWrap always); .prettierignore keeps alpine.min.js untouched.
# Globs are quoted so prettier expands them, not the shell.
run_yarn run prettier --write 'static/js/*.js' '**/*.md'
} }
main "$@" main "$@"
+14 -2
View File
@@ -1,12 +1,24 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. # script/lint: run golangci-lint. The linter is never installed on the
# host; it runs only inside Docker, from the pinned image in
# Dockerfile.lint, so every run uses the same linter version everywhere.
# Linting is a build step there, so a successful build is a clean lint.
#
# GATE_RUN differs every run so the lint layer always executes; a cached
# build would otherwise exit 0 in under a second having linted nothing.
# --output=type=cacheonly discards the image and keeps only build cache,
# so no tagged image is left behind.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
golangci-lint run --config .golangci.yml ./... docker build \
--build-arg GATE_RUN="$(date +%s)-$$" \
--output=type=cacheonly \
-f Dockerfile.lint \
.
} }
main "$@" main "$@"
+1 -1
View File
@@ -59,7 +59,7 @@ document.addEventListener("alpine:init", () => {
}, },
submitAll() { submitAll() {
const csrfInput = this.$el.querySelector( const csrfInput = this.$root.querySelector(
'input[name="gorilla.csrf.Token"]', 'input[name="gorilla.csrf.Token"]',
); );
const csrfToken = csrfInput ? csrfInput.value : ""; const csrfToken = csrfInput ? csrfInput.value : "";
+1 -1
View File
@@ -322,7 +322,7 @@
</td> </td>
<td class="text-right"> <td class="text-right">
<form method="POST" action="/apps/{{$.App.ID}}/ports/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this port mapping?')" @submit="confirm($event)"> <form method="POST" action="/apps/{{$.App.ID}}/ports/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this port mapping?')" @submit="confirm($event)">
{{ .CSRFField }} {{ $.CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button> <button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form> </form>
</td> </td>
+8
View File
@@ -0,0 +1,8 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
prettier@3.8.1:
version "3.8.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==