Compare commits

..

2 Commits

Author SHA1 Message Date
clawbot
b593f544d2 fix: address review findings for observability PR
All checks were successful
Check / check (pull_request) Successful in 3m10s
1. Security: Replace insecure extractRemoteIP() in audit service with
   middleware.RealIP() which validates trusted proxies before trusting
   X-Real-IP/X-Forwarded-For headers. Export RealIP from middleware.
   Update audit tests to verify anti-spoofing behavior.

2. Audit coverage: Add audit instrumentation to all 9 handlers that
   had dead action constants: HandleEnvVarSave, HandleLabelAdd,
   HandleLabelEdit, HandleLabelDelete, HandleVolumeAdd, HandleVolumeEdit,
   HandleVolumeDelete, HandlePortAdd, HandlePortDelete.

3. README: Fix API path from /api/audit to /api/v1/audit.

4. README: Fix duplicate numbering in DI order section (items 10-11
   were listed twice, now correctly numbered 10-16).
2026-03-19 22:50:44 -07:00
clawbot
96f391282e feat: add observability improvements (metrics, audit log, structured logging)
- Add Prometheus metrics package (internal/metrics) with deployment,
  container health, webhook, HTTP request, and audit counters/histograms
- Add audit_log SQLite table via migration 007
- Add AuditEntry model with CRUD operations and query methods
- Add audit service (internal/service/audit) for recording user actions
- Instrument deploy service with deployment duration, count, and
  in-flight metrics; container health gauge updates on deploy completion
- Instrument webhook service with event counters by app/type/matched
- Instrument HTTP middleware with request count, duration, and response
  size metrics; also log response bytes in structured request logs
- Add audit logging to all key handler operations: login/logout, app
  CRUD, deploy, cancel, rollback, restart/stop/start, webhook receipt,
  and initial setup
- Add GET /api/audit endpoint for querying recent audit entries
- Make /metrics endpoint always available (optionally auth-protected)
- Add comprehensive tests for metrics, audit model, and audit service
- Update existing test infrastructure with metrics and audit dependencies
- Update README with Observability section documenting all metrics,
  audit log, and structured logging
2026-03-19 22:48:32 -07:00
69 changed files with 2287 additions and 2999 deletions

View File

@@ -13,4 +13,4 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4, 2024-10-13
- name: Build (runs make check inside Dockerfile)
run: script/cibuild
run: docker build .

View File

@@ -1,9 +1,5 @@
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:
timeout: 5m
modules-download-mode: readonly
@@ -18,17 +14,19 @@ linters:
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
linters-settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

View File

@@ -1,6 +1,6 @@
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
# golangci/golangci-lint:v2.10.1
FROM golangci/golangci-lint@sha256:ea84d14c2fef724411be7dc45e09e6ef721d748315252b02df19a7e3113ee763 AS lint
WORKDIR /src
COPY go.mod go.sum ./

View File

@@ -1,4 +1,4 @@
.PHONY: all bootstrap setup build lint fmt fmt-check test check clean docker hooks
.PHONY: all build lint fmt fmt-check test check clean docker hooks
BINARY := upaasd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@@ -7,37 +7,37 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build
bootstrap:
@script/bootstrap
setup:
@script/setup
build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/upaasd
lint:
@script/lint
golangci-lint run --config .golangci.yml ./...
fmt:
@script/fmt
gofmt -s -w .
goimports -w .
npx prettier --write --tab-width 4 static/js/*.js
fmt-check:
@script/fmt-check
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
test:
@script/test
go test -v -race -cover -timeout 30s ./...
# Check runs all validation without making changes
# Used by CI and Docker build - fails if anything is wrong
check:
@script/check
check: fmt-check lint test
@echo "==> All checks passed!"
docker:
@script/docker
docker build .
hooks:
@script/install-precommit
@echo "Installing pre-commit hook..."
@mkdir -p .git/hooks
@printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "Pre-commit hook installed."
clean:
rm -rf bin/

107
README.md
View File

@@ -1,12 +1,12 @@
# µPaaS by [@sneak](https://sneak.berlin)
A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via webhooks from Gitea, GitHub, or GitLab.
A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via Gitea webhooks.
## Features
- Single admin user with argon2id password hashing
- Per-app SSH keypairs for read-only deploy keys
- Per-app UUID-based webhook URLs with auto-detection of Gitea, GitHub, and GitLab
- Per-app UUID-based webhook URLs for Gitea integration
- Branch filtering - only deploy on configured branch changes
- Environment variables, labels, and volume mounts per app
- CPU and memory resource limits per app
@@ -20,7 +20,7 @@ A simple self-hosted PaaS that auto-deploys Docker containers from Git repositor
- Complex CI pipelines
- Multiple container orchestration
- SPA/API-first design
- Support for non-push webhook events (e.g. issues, merge requests)
- Support for non-Gitea webhooks
## Architecture
@@ -37,15 +37,17 @@ upaas/
│ ├── handlers/ # HTTP request handlers
│ ├── healthcheck/ # Health status service
│ ├── logger/ # Structured logging (slog)
│ ├── middleware/ # HTTP middleware (auth, logging, CORS)
│ ├── metrics/ # Prometheus metrics registration
│ ├── middleware/ # HTTP middleware (auth, logging, CORS, metrics)
│ ├── models/ # Active Record style database models
│ ├── server/ # HTTP server and routes
│ ├── service/
│ │ ├── app/ # App management service
│ │ ├── audit/ # Audit logging service
│ │ ├── auth/ # Authentication service
│ │ ├── deploy/ # Deployment orchestration
│ │ ├── notify/ # Notifications (ntfy, Slack)
│ │ └── webhook/ # Webhook processing (Gitea, GitHub, GitLab)
│ │ └── webhook/ # Gitea webhook processing
│ └── ssh/ # SSH key generation
├── static/ # Embedded CSS/JS assets
└── templates/ # Embedded HTML templates
@@ -59,16 +61,18 @@ Uses Uber fx for dependency injection. Components are wired in this order:
2. `logger` - Structured logging
3. `config` - Configuration loading
4. `database` - SQLite connection + migrations
5. `healthcheck` - Health status
6. `auth` - Authentication service
7. `app` - App management
8. `docker` - Docker client
9. `notify` - Notification service
10. `deploy` - Deployment service
11. `webhook` - Webhook processing
12. `middleware` - HTTP middleware
13. `handlers` - HTTP handlers
14. `server` - HTTP server
5. `metrics` - Prometheus metrics registration
6. `healthcheck` - Health status
7. `auth` - Authentication service
8. `app` - App management
9. `docker` - Docker client
10. `notify` - Notification service
11. `audit` - Audit logging service
12. `deploy` - Deployment service
13. `webhook` - Webhook processing
14. `middleware` - HTTP middleware
15. `handlers` - HTTP handlers
16. `server` - HTTP server
### Request Flow
@@ -100,31 +104,6 @@ chi Router ──► Middleware Stack ──► Handler
- **Async deployments**: Webhook triggers deploy via goroutine with `context.WithoutCancel()`
- **Embedded assets**: Templates and static files embedded via `//go:embed`
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call
them. We provide:
- `script/bootstrap` — install all dependencies (idempotent)
- `script/setup` — make a fresh clone ready for development
(bootstrap, then install-precommit)
- `script/projectname` — output the project name ("upaas")
- `script/test` — run the test suite
- `script/lint` — run golangci-lint
- `script/fmt` — format all code (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
runs the checks, so a green build implies a green repo)
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
`script/check`)
- `script/install-precommit` — install the git pre-commit hook that
runs `script/precommit`
## Development
### Prerequisites
@@ -136,16 +115,14 @@ them. We provide:
### Commands
```bash
make bootstrap # Install all dependencies (idempotent)
make setup # Bootstrap + install git pre-commit hook
make fmt # Format code
make fmt-check # Check formatting (read-only, fails if unformatted)
make lint # Run comprehensive linting
make test # Run tests with race detection (30s timeout)
make check # Verify everything passes (test, lint, fmt-check)
make check # Verify everything passes (fmt-check, lint, test)
make build # Build binary
make docker # Build Docker image
make hooks # Install pre-commit hook (runs script/precommit)
make hooks # Install pre-commit hook (runs make check)
```
### Commit Requirements
@@ -239,6 +216,48 @@ 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`.
## Observability
### Prometheus Metrics
All custom metrics are exposed under the `upaas_` namespace at `/metrics`. The
endpoint is always available and can be optionally protected with basic auth via
`METRICS_USERNAME` and `METRICS_PASSWORD`.
| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `upaas_deployments_total` | Counter | `app`, `status` | Total deployments (success/failed/cancelled) |
| `upaas_deployments_duration_seconds` | Histogram | `app`, `status` | Deployment duration |
| `upaas_deployments_in_flight` | Gauge | `app` | Currently running deployments |
| `upaas_container_healthy` | Gauge | `app` | Container health (1=healthy, 0=unhealthy) |
| `upaas_webhook_events_total` | Counter | `app`, `event_type`, `matched` | Webhook events received |
| `upaas_http_requests_total` | Counter | `method`, `status_code` | HTTP requests |
| `upaas_http_request_duration_seconds` | Histogram | `method` | HTTP request latency |
| `upaas_http_response_size_bytes` | Histogram | `method` | HTTP response sizes |
| `upaas_audit_events_total` | Counter | `action` | Audit log events |
### Audit Log
All user-facing actions are recorded in an `audit_log` SQLite table with:
- **Who**: user ID and username
- **What**: action type and affected resource (app, deployment, session, etc.)
- **Where**: client IP (via X-Real-IP/X-Forwarded-For/RemoteAddr)
- **When**: timestamp
Audited actions include login/logout, app CRUD, deployments, container
start/stop/restart, rollbacks, deployment cancellation, and webhook receipt.
The audit log is available via the API at `GET /api/v1/audit?limit=N` (max 500,
default 50).
### Structured Logging
All operations use Go's `slog` structured logger. HTTP requests are logged with
method, URL, status code, response size, latency, user agent, and client IP.
Deployment events are logged with app name, status, and duration. Audit events
are also logged to stdout for correlation with external log aggregators.
## License
WTFPL

View File

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

49
TODO.md
View File

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

View File

@@ -11,9 +11,11 @@ import (
"sneak.berlin/go/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/healthcheck"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/metrics"
"sneak.berlin/go/upaas/internal/middleware"
"sneak.berlin/go/upaas/internal/server"
"sneak.berlin/go/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/audit"
"sneak.berlin/go/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/notify"
@@ -41,6 +43,7 @@ func main() {
logger.New,
config.New,
database.New,
metrics.New,
healthcheck.New,
auth.New,
app.New,
@@ -48,6 +51,7 @@ func main() {
notify.New,
deploy.New,
webhook.New,
audit.New,
middleware.New,
handlers.New,
server.New,

View File

@@ -45,7 +45,7 @@ type Config struct {
Port int
Debug bool
DataDir string
HostDataDir string // Host path for DataDir (Docker bind mounts in container)
HostDataDir string // Host path for DataDir (for Docker bind mounts when running in container)
DockerHost string
SentryDSN string
MaintenanceMode bool

View File

@@ -178,8 +178,7 @@ func HashWebhookSecret(secret string) string {
func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error {
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 {
return fmt.Errorf("querying apps for backfill: %w", err)
}

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
-- Audit log table for tracking user actions.
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY,
user_id INTEGER,
username TEXT NOT NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
detail TEXT,
remote_ip TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_audit_log_created_at ON audit_log(created_at);
CREATE INDEX idx_audit_log_action ON audit_log(action);
CREATE INDEX idx_audit_log_resource ON audit_log(resource_type, resource_id);

View File

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

View File

@@ -41,8 +41,7 @@ const stopTimeoutSeconds = 10
// gitImage is the Docker image used for git operations.
// 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.
var ErrNotConnected = errors.New("docker client not connected")
@@ -146,7 +145,7 @@ type CreateContainerOptions struct {
Volumes []VolumeMount
Ports []PortMapping
Network string
CPULimit float64 // CPU cores (0.5 = half a core). 0 means unlimited.
CPULimit float64 // CPU cores (e.g. 0.5 = half a core, 2.0 = two cores). 0 means unlimited.
MemoryLimit int64 // Memory in bytes. 0 means unlimited.
}
@@ -304,11 +303,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) err
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 {
return fmt.Errorf("failed to stop container: %w", err)
}
@@ -328,11 +323,7 @@ func (c *Client) RemoveContainer(
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 {
return fmt.Errorf("failed to remove container: %w", err)
}
@@ -478,8 +469,7 @@ type CloneResult struct {
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).
// hostDir is the corresponding path on the Docker host (for bind mounts).
// If commitSHA is provided, that specific commit will be checked out.
@@ -594,13 +584,11 @@ func (c *Client) performBuild(
// scannerInitialBufferSize is the initial buffer size for the build log scanner.
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
// streamBuildOutput reads Docker build output line by line and writes to
// stdout and optional log writer. Docker sends newline-delimited JSON, so
// reading line by line ensures each log entry is written immediately.
// streamBuildOutput reads Docker build output line by line and writes to 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 {
scanner := bufio.NewScanner(body)
buf := make([]byte, 0, scannerInitialBufferSize)
@@ -628,10 +616,7 @@ func (c *Client) streamBuildOutput(body io.Reader, logWriter io.Writer) error {
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
err := os.MkdirAll(cfg.containerDir, workDirPermissions)
if err != nil {
@@ -657,11 +642,7 @@ func (c *Client) performClone(
}
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)
@@ -699,8 +680,7 @@ func (c *Client) createGitContainer(
entrypoint := []string{}
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,
&container.Config{
Image: gitImage,
@@ -731,20 +711,13 @@ func (c *Client) createGitContainer(
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{})
if err != nil {
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 {
case err := <-errCh:

View File

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

View File

@@ -1,6 +1,7 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
@@ -84,7 +85,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil {
h.respondJSON(writer, request,
map[string]string{jsonKeyError: "invalid JSON body"},
map[string]string{"error": "invalid JSON body"},
http.StatusBadRequest)
return
@@ -95,7 +96,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
if username == "" || credential == "" {
h.respondJSON(writer, request,
map[string]string{jsonKeyError: "username and password are required"},
map[string]string{"error": "username and password are required"},
http.StatusBadRequest)
return
@@ -104,7 +105,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
user, authErr := h.auth.Authenticate(request.Context(), username, credential)
if authErr != nil {
h.respondJSON(writer, request,
map[string]string{jsonKeyError: "invalid credentials"},
map[string]string{"error": "invalid credentials"},
http.StatusUnauthorized)
return
@@ -114,12 +115,15 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
if sessionErr != nil {
h.log.Error("api: failed to create session", "error", sessionErr)
h.respondJSON(writer, request,
map[string]string{jsonKeyError: "failed to create session"},
map[string]string{"error": "failed to create session"},
http.StatusInternalServerError)
return
}
h.auditLog(request, models.AuditActionLogin,
models.AuditResourceSession, "", "api login")
h.respondJSON(writer, request, loginResponse{
UserID: user.ID,
Username: user.Username,
@@ -133,7 +137,7 @@ func (h *Handlers) HandleAPIListApps() http.HandlerFunc {
apps, err := h.appService.ListApps(request.Context())
if err != nil {
h.respondJSON(writer, request,
map[string]string{jsonKeyError: "failed to list apps"},
map[string]string{"error": "failed to list apps"},
http.StatusInternalServerError)
return
@@ -156,7 +160,7 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil {
h.respondJSON(writer, request,
map[string]string{jsonKeyError: "internal server error"},
map[string]string{"error": "internal server error"},
http.StatusInternalServerError)
return
@@ -164,7 +168,7 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
if application == nil {
h.respondJSON(writer, request,
map[string]string{jsonKeyError: "app not found"},
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
@@ -185,7 +189,7 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil || application == nil {
h.respondJSON(writer, request,
map[string]string{jsonKeyError: "app not found"},
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
@@ -205,7 +209,7 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
)
if deployErr != nil {
h.respondJSON(writer, request,
map[string]string{jsonKeyError: "failed to list deployments"},
map[string]string{"error": "failed to list deployments"},
http.StatusInternalServerError)
return
@@ -231,7 +235,7 @@ func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
user, err := h.auth.GetCurrentUser(request.Context(), request)
if err != nil || user == nil {
h.respondJSON(writer, request,
map[string]string{jsonKeyError: "unauthorized"},
map[string]string{"error": "unauthorized"},
http.StatusUnauthorized)
return
@@ -243,3 +247,79 @@ func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
}, http.StatusOK)
}
}
// auditLogDefaultLimit is the default number of audit entries returned.
const auditLogDefaultLimit = 50
// auditLogMaxLimit is the maximum number of audit entries returned.
const auditLogMaxLimit = 500
// HandleAPIAuditLog returns a handler that lists recent audit log entries.
func (h *Handlers) HandleAPIAuditLog() http.HandlerFunc {
type auditEntryResponse struct {
ID int64 `json:"id"`
UserID *int64 `json:"userId,omitempty"`
Username string `json:"username"`
Action string `json:"action"`
ResourceType string `json:"resourceType"`
ResourceID string `json:"resourceId,omitempty"`
Detail string `json:"detail,omitempty"`
RemoteIP string `json:"remoteIp,omitempty"`
CreatedAt string `json:"createdAt"`
}
return func(writer http.ResponseWriter, request *http.Request) {
limit := auditLogDefaultLimit
if limitStr := request.URL.Query().Get("limit"); limitStr != "" {
parsed, parseErr := strconv.Atoi(limitStr)
if parseErr == nil && parsed > 0 && parsed <= auditLogMaxLimit {
limit = parsed
}
}
entries, err := h.audit.Recent(request.Context(), limit)
if err != nil {
h.log.Error("failed to fetch audit log", "error", err)
h.respondJSON(writer, request,
map[string]string{"error": "failed to fetch audit log"},
http.StatusInternalServerError)
return
}
result := make([]auditEntryResponse, 0, len(entries))
for _, e := range entries {
entry := auditEntryResponse{
ID: e.ID,
Username: e.Username,
Action: string(e.Action),
ResourceType: string(e.ResourceType),
CreatedAt: e.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
if e.UserID.Valid {
id := e.UserID.Int64
entry.UserID = &id
}
entry.ResourceID = nullStringValue(e.ResourceID)
entry.Detail = nullStringValue(e.Detail)
entry.RemoteIP = nullStringValue(e.RemoteIP)
result = append(result, entry)
}
h.respondJSON(writer, request, result, http.StatusOK)
}
}
// nullStringValue returns the string value if valid, empty string otherwise.
func nullStringValue(ns sql.NullString) string {
if ns.Valid {
return ns.String
}
return ""
}

View File

@@ -47,12 +47,7 @@ func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
r := apiRouter(tc)
loginBody := `{"username":"admin","password":"password123"}`
req := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost,
"/api/v1/login",
strings.NewReader(loginBody),
)
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
@@ -75,7 +70,7 @@ func apiGet(
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
req := httptest.NewRequest(http.MethodGet, path, nil)
for _, c := range cookies {
req.AddCookie(c)
@@ -100,12 +95,7 @@ func TestAPILoginSuccess(t *testing.T) {
r := apiRouter(tc)
body := `{"username":"admin","password":"password123"}`
req := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost,
"/api/v1/login",
strings.NewReader(body),
)
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
@@ -132,12 +122,7 @@ func TestAPILoginInvalidCredentials(t *testing.T) {
r := apiRouter(tc)
body := `{"username":"admin","password":"wrong"}`
req := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost,
"/api/v1/login",
strings.NewReader(body),
)
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
@@ -154,12 +139,7 @@ func TestAPILoginMissingFields(t *testing.T) {
r := apiRouter(tc)
body := `{"username":"","password":""}`
req := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost,
"/api/v1/login",
strings.NewReader(body),
)
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
@@ -175,9 +155,7 @@ func TestAPIRejectsUnauthenticated(t *testing.T) {
r := apiRouter(tc)
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/api/v1/apps", nil,
)
req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)

View File

@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -16,7 +15,6 @@ import (
"github.com/go-chi/chi/v5"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app"
"sneak.berlin/go/upaas/templates"
@@ -29,23 +27,6 @@ const (
deploymentsHistoryLimit = 50
)
// redirectToApp issues a SeeOther redirect to the page for the given
// app ID, with an optional suffix such as "/deployments" or
// "?success=updated". The ID is path-escaped so the target is always
// a relative application URL.
func redirectToApp(
writer http.ResponseWriter,
request *http.Request,
appID, suffix string,
) {
http.Redirect(
writer,
request,
"/apps/"+url.PathEscape(appID)+suffix,
http.StatusSeeOther,
)
}
// HandleAppNew returns the new app form handler.
func (h *Handlers) HandleAppNew() http.HandlerFunc {
tmpl := templates.GetParsed()
@@ -58,9 +39,7 @@ func (h *Handlers) HandleAppNew() http.HandlerFunc {
}
// HandleAppCreate handles app creation.
//
//nolint:funlen // validation adds necessary length
func (h *Handlers) HandleAppCreate() http.HandlerFunc {
func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
@@ -140,6 +119,9 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc {
return
}
h.auditLog(request, models.AuditActionAppCreate,
models.AuditResourceApp, createdApp.ID, "created app: "+createdApp.Name)
http.Redirect(writer, request, "/apps/"+createdApp.ID, http.StatusSeeOther)
}
}
@@ -181,14 +163,10 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
}
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{
dataKeyApp: application,
"App": application,
"EnvVars": envVars,
"Labels": labels,
"Volumes": volumes,
@@ -226,7 +204,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
}
data := h.addGlobals(map[string]any{
dataKeyApp: application,
"App": application,
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -234,7 +212,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
}
// HandleAppUpdate handles app updates.
func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
@@ -259,8 +237,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
nameErr := validateAppName(newName)
if nameErr != nil {
data := h.addGlobals(map[string]any{
dataKeyApp: application,
dataKeyError: "Invalid app name: " + nameErr.Error(),
"App": application,
"Error": "Invalid app name: " + nameErr.Error(),
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -270,8 +248,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
repoURLErr := validateRepoURL(request.FormValue("repo_url"))
if repoURLErr != nil {
data := h.addGlobals(map[string]any{
dataKeyApp: application,
dataKeyError: "Invalid repository URL: " + repoURLErr.Error(),
"App": application,
"Error": "Invalid repository URL: " + repoURLErr.Error(),
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -289,8 +267,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
limitsErr := applyResourceLimits(application, request)
if limitsErr != "" {
data := h.addGlobals(map[string]any{
dataKeyApp: application,
dataKeyError: limitsErr,
"App": application,
"Error": limitsErr,
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -302,15 +280,19 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
h.log.Error("failed to update app", "error", saveErr)
data := h.addGlobals(map[string]any{
dataKeyApp: application,
dataKeyError: "Failed to update app",
"App": application,
"Error": "Failed to update app",
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
return
}
redirectToApp(writer, request, application.ID, "?success=updated")
h.auditLog(request, models.AuditActionAppUpdate,
models.AuditResourceApp, application.ID, "updated app: "+application.Name)
redirectURL := "/apps/" + application.ID + "?success=updated"
http.Redirect(writer, request, redirectURL, http.StatusSeeOther)
}
}
@@ -364,6 +346,9 @@ func (h *Handlers) HandleAppDelete() http.HandlerFunc {
return
}
h.auditLog(request, models.AuditActionAppDelete,
models.AuditResourceApp, appID, "deleted app: "+application.Name)
http.Redirect(writer, request, "/", http.StatusSeeOther)
}
}
@@ -380,6 +365,9 @@ func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
return
}
h.auditLog(request, models.AuditActionAppDeploy,
models.AuditResourceApp, application.ID, "manual deploy: "+application.Name)
// Trigger deployment in background with a detached context
// so the deployment continues even if the HTTP request is cancelled
deployCtx := context.WithoutCancel(request.Context())
@@ -395,7 +383,12 @@ func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
}
}(deployCtx, application)
redirectToApp(writer, request, application.ID, "/deployments")
http.Redirect(
writer,
request,
"/apps/"+application.ID+"/deployments",
http.StatusSeeOther,
)
}
}
@@ -414,9 +407,16 @@ func (h *Handlers) HandleCancelDeploy() http.HandlerFunc {
cancelled := h.deploy.CancelDeploy(application.ID)
if cancelled {
h.log.Info("deployment cancelled by user", "app", application.Name)
h.auditLog(request, models.AuditActionDeployCancel,
models.AuditResourceDeployment, application.ID, "cancelled deploy: "+application.Name)
}
redirectToApp(writer, request, application.ID, "")
http.Redirect(
writer,
request,
"/apps/"+application.ID,
http.StatusSeeOther,
)
}
}
@@ -435,12 +435,15 @@ func (h *Handlers) HandleAppRollback() http.HandlerFunc {
rollbackErr := h.deploy.Rollback(request.Context(), application)
if rollbackErr != nil {
h.log.Error("rollback failed", "error", rollbackErr, "app", application.Name)
redirectToApp(writer, request, application.ID, "")
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
redirectToApp(writer, request, application.ID, "?success=rolledback")
h.auditLog(request, models.AuditActionAppRollback,
models.AuditResourceApp, application.ID, "rolled back: "+application.Name)
http.Redirect(writer, request, "/apps/"+application.ID+"?success=rolledback", http.StatusSeeOther)
}
}
@@ -464,7 +467,7 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
)
data := h.addGlobals(map[string]any{
dataKeyApp: application,
"App": application,
"Deployments": deployments,
}, request)
@@ -537,7 +540,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return
}
_, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- output sanitized
_, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- logs sanitized, Content-Type is text/plain
}
}
@@ -576,8 +579,8 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
}
response := map[string]any{
jsonKeyLogs: logs,
jsonKeyStatus: deployment.Status,
"logs": logs,
"status": deployment.Status,
}
_ = json.NewEncoder(writer).Encode(response)
@@ -620,7 +623,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
}
// Check if file exists — logPath is constructed internally, not from user input
_, err := os.Stat(logPath) // #nosec G703 -- internal path, not user input
_, err := os.Stat(logPath) // #nosec G703 -- path from internal GetLogFilePath, not user input
if os.IsNotExist(err) {
http.NotFound(writer, request)
@@ -640,7 +643,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
http.ServeFile(writer, request, logPath) // #nosec G703 -- internal path
http.ServeFile(writer, request, logPath)
}
}
@@ -664,8 +667,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID)
if containerErr != nil || containerInfo == nil {
response := map[string]any{
jsonKeyLogs: "No container running\n",
jsonKeyStatus: "stopped",
"logs": "No container running\n",
"status": "stopped",
}
_ = json.NewEncoder(writer).Encode(response)
@@ -685,8 +688,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
)
response := map[string]any{
jsonKeyLogs: "Failed to fetch container logs\n",
jsonKeyStatus: "error",
"logs": "Failed to fetch container logs\n",
"status": "error",
}
_ = json.NewEncoder(writer).Encode(response)
@@ -699,8 +702,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
}
response := map[string]any{
jsonKeyLogs: SanitizeLogs(logs),
jsonKeyStatus: status,
"logs": SanitizeLogs(logs),
"status": status,
}
_ = json.NewEncoder(writer).Encode(response)
@@ -734,7 +737,7 @@ func (h *Handlers) HandleAppStatusAPI() http.HandlerFunc {
}
response := map[string]any{
jsonKeyStatus: string(application.Status),
"status": string(application.Status),
"latestDeploymentID": latestDeploymentID,
"latestDeploymentStatus": latestDeploymentStatus,
}
@@ -771,7 +774,7 @@ func (h *Handlers) HandleRecentDeploymentsAPI() http.HandlerFunc {
for _, d := range deployments {
deploymentsData = append(deploymentsData, map[string]any{
"id": d.ID,
jsonKeyStatus: string(d.Status),
"status": string(d.Status),
"duration": d.Duration(),
"shortCommit": d.ShortCommit(),
"finishedAtISO": d.FinishedAtISO(),
@@ -813,7 +816,7 @@ func (h *Handlers) handleContainerAction(
containerInfo, containerErr := h.docker.FindContainerByAppID(ctx, appID)
if containerErr != nil || containerInfo == nil {
redirectToApp(writer, request, appID, "")
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
@@ -844,9 +847,27 @@ func (h *Handlers) handleContainerAction(
} else {
h.log.Info("container action completed",
"action", action, "app", application.Name, "container", containerID)
auditAction := containerActionToAuditAction(action)
h.auditLog(request, auditAction,
models.AuditResourceApp, appID, string(action)+" container: "+application.Name)
}
redirectToApp(writer, request, appID, "")
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
// containerActionToAuditAction maps container actions to audit actions.
func containerActionToAuditAction(action containerAction) models.AuditAction {
switch action {
case actionRestart:
return models.AuditActionAppRestart
case actionStop:
return models.AuditActionAppStop
case actionStart:
return models.AuditActionAppStart
default:
return models.AuditAction("app." + string(action))
}
}
// HandleAppRestart handles restarting an app's container.
@@ -900,7 +921,7 @@ func (h *Handlers) addKeyValueToApp(
value := request.FormValue("value")
if key == "" || value == "" {
redirectToApp(writer, request, application.ID, "")
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
@@ -910,7 +931,7 @@ func (h *Handlers) addKeyValueToApp(
h.log.Error("failed to add key-value pair", "error", saveErr)
}
redirectToApp(writer, request, application.ID, "")
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
}
// envPairJSON represents a key-value pair in the JSON request body.
@@ -971,7 +992,7 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
decodeErr := json.NewDecoder(request.Body).Decode(&pairs)
if decodeErr != nil {
h.respondJSON(writer, request, map[string]string{
jsonKeyError: "invalid request body",
"error": "invalid request body",
}, http.StatusBadRequest)
return
@@ -980,7 +1001,7 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
modelPairs, validationErr := validateEnvPairs(pairs)
if validationErr != "" {
h.respondJSON(writer, request, map[string]string{
jsonKeyError: validationErr,
"error": validationErr,
}, http.StatusBadRequest)
return
@@ -992,12 +1013,16 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
if replaceErr != nil {
h.log.Error("failed to replace env vars", "error", replaceErr)
h.respondJSON(writer, request, map[string]string{
jsonKeyError: "failed to save environment variables",
"error": "failed to save environment variables",
}, http.StatusInternalServerError)
return
}
h.auditLog(request, models.AuditActionEnvVarSave,
models.AuditResourceEnvVar, application.ID,
fmt.Sprintf("saved %d env vars", len(modelPairs)))
h.respondJSON(writer, request, map[string]bool{"ok": true}, http.StatusOK)
}
}
@@ -1014,83 +1039,47 @@ func (h *Handlers) HandleLabelAdd() http.HandlerFunc {
label.Key = key
label.Value = value
return label.Save(ctx)
err := label.Save(ctx)
if err == nil {
h.auditLog(request, models.AuditActionLabelAdd,
models.AuditResourceLabel, application.ID, "added label: "+key)
}
return err
},
)
}
}
// deleteAppResource handles deletion of an app-owned resource (label,
// volume, or port) identified by an int64 URL parameter. The
// 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")
idStr := chi.URLParam(request, idParam)
id, parseErr := strconv.ParseInt(idStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
found, deleteErr := deleteByID(request.Context(), appID, id)
if !found {
http.NotFound(writer, request)
return
}
if deleteErr != nil {
h.log.Error("failed to delete "+logName, "error", deleteErr)
}
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,
),
)
appID := chi.URLParam(request, "id")
labelIDStr := chi.URLParam(request, "labelID")
labelID, parseErr := strconv.ParseInt(labelIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
label, findErr := models.FindLabel(request.Context(), h.db, labelID)
if findErr != nil || label == nil || label.AppID != appID {
http.NotFound(writer, request)
return
}
deleteErr := label.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete label", "error", deleteErr)
} else {
h.auditLog(request, models.AuditActionLabelDelete,
models.AuditResourceLabel, appID, "deleted label: "+label.Key)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
@@ -1118,7 +1107,12 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
readOnly := request.FormValue("readonly") == "1"
if hostPath == "" || containerPath == "" {
redirectToApp(writer, request, application.ID, "")
http.Redirect(
writer,
request,
"/apps/"+application.ID,
http.StatusSeeOther,
)
return
}
@@ -1126,7 +1120,7 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
pathErr := validateVolumePaths(hostPath, containerPath)
if pathErr != nil {
h.log.Error("invalid volume path", "error", pathErr)
redirectToApp(writer, request, application.ID, "")
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
@@ -1140,22 +1134,46 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
saveErr := volume.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to add volume", "error", saveErr)
} else {
h.auditLog(request, models.AuditActionVolumeAdd,
models.AuditResourceVolume, application.ID,
"added volume: "+hostPath+":"+containerPath)
}
redirectToApp(writer, request, application.ID, "")
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
}
}
// HandleVolumeDelete handles deleting a volume mount.
func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
h.deleteAppResource(
writer, request, "volumeID", "volume",
makeDeleteByID(h.db, models.FindVolume,
func(v *models.Volume) string { return v.AppID },
(*models.Volume).Delete,
),
)
appID := chi.URLParam(request, "id")
volumeIDStr := chi.URLParam(request, "volumeID")
volumeID, parseErr := strconv.ParseInt(volumeIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
volume, findErr := models.FindVolume(request.Context(), h.db, volumeID)
if findErr != nil || volume == nil || volume.AppID != appID {
http.NotFound(writer, request)
return
}
deleteErr := volume.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete volume", "error", deleteErr)
} else {
h.auditLog(request, models.AuditActionVolumeDelete,
models.AuditResourceVolume, appID,
"deleted volume: "+volume.HostPath+":"+volume.ContainerPath)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
@@ -1183,7 +1201,7 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
request.FormValue("container_port"),
)
if !valid {
redirectToApp(writer, request, application.ID, "")
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
@@ -1202,9 +1220,13 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
saveErr := port.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to save port", "error", saveErr)
} else {
h.auditLog(request, models.AuditActionPortAdd,
models.AuditResourcePort, application.ID,
fmt.Sprintf("added port: %d:%d/%s", hostPort, containerPort, protocol))
}
redirectToApp(writer, request, application.ID, "")
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
}
}
@@ -1228,13 +1250,33 @@ func parsePortValues(hostPortStr, containerPortStr string) (int, int, bool) {
// HandlePortDelete handles deleting a port mapping.
func (h *Handlers) HandlePortDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
h.deleteAppResource(
writer, request, "portID", "port",
makeDeleteByID(h.db, models.FindPort,
func(p *models.Port) string { return p.AppID },
(*models.Port).Delete,
),
)
appID := chi.URLParam(request, "id")
portIDStr := chi.URLParam(request, "portID")
portID, parseErr := strconv.ParseInt(portIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
port, findErr := models.FindPort(request.Context(), h.db, portID)
if findErr != nil || port == nil || port.AppID != appID {
http.NotFound(writer, request)
return
}
deleteErr := port.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete port", "error", deleteErr)
} else {
h.auditLog(request, models.AuditActionPortDelete,
models.AuditResourcePort, appID,
fmt.Sprintf("deleted port: %d:%d/%s", port.HostPort, port.ContainerPort, port.Protocol))
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
@@ -1296,7 +1338,7 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
value := request.FormValue("value")
if key == "" || value == "" {
redirectToApp(writer, request, appID, "")
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
@@ -1307,9 +1349,12 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
saveErr := label.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update label", "error", saveErr)
} else {
h.auditLog(request, models.AuditActionLabelEdit,
models.AuditResourceLabel, appID, "edited label: "+key)
}
redirectToApp(writer, request, appID, "")
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
@@ -1345,7 +1390,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
readOnly := request.FormValue("readonly") == "1"
if hostPath == "" || containerPath == "" {
redirectToApp(writer, request, appID, "")
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
@@ -1353,7 +1398,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
pathErr := validateVolumePaths(hostPath, containerPath)
if pathErr != nil {
h.log.Error("invalid volume path", "error", pathErr)
redirectToApp(writer, request, appID, "")
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
@@ -1365,9 +1410,13 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
saveErr := volume.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update volume", "error", saveErr)
} else {
h.auditLog(request, models.AuditActionVolumeEdit,
models.AuditResourceVolume, appID,
"edited volume: "+hostPath+":"+containerPath)
}
redirectToApp(writer, request, appID, "")
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
@@ -1411,9 +1460,8 @@ func optionalNullString(s string) sql.NullString {
return sql.NullString{}
}
// applyResourceLimits parses CPU and memory limit form values and
// applies them to the app. Returns an error message string if
// validation fails, or empty string on success.
// applyResourceLimits parses CPU and memory limit form values and applies them to the app.
// Returns an error message string if validation fails, or empty string on success.
func applyResourceLimits(application *models.App, request *http.Request) string {
cpuLimit, cpuErr := parseOptionalFloat64(request.FormValue("cpu_limit"))
if cpuErr != nil {
@@ -1448,8 +1496,7 @@ func memoryUnitMultiplier(suffix byte) int64 {
}
// 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 error if the string is non-empty but invalid or non-positive.
func parseOptionalFloat64(s string) (sql.NullFloat64, error) {
@@ -1471,8 +1518,7 @@ func parseOptionalFloat64(s string) (sql.NullFloat64, error) {
}
// 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.
func parseOptionalMemoryBytes(s string) (sql.NullInt64, error) {
s = strings.TrimSpace(s)

View File

@@ -21,16 +21,8 @@ func TestValidateAppName(t *testing.T) {
{"empty", "", true},
{"single char", "a", 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},
{"spaces", "my app", true},
{"starts with hyphen", "-myapp", true},

View File

@@ -3,6 +3,7 @@ package handlers
import (
"net/http"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/templates"
)
@@ -61,6 +62,9 @@ func (h *Handlers) HandleLoginPOST() http.HandlerFunc {
return
}
h.auditLog(request, models.AuditActionLogin,
models.AuditResourceSession, "", "user logged in")
http.Redirect(writer, request, "/", http.StatusSeeOther)
}
}
@@ -68,6 +72,9 @@ func (h *Handlers) HandleLoginPOST() http.HandlerFunc {
// HandleLogout handles logout requests.
func (h *Handlers) HandleLogout() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
h.auditLog(request, models.AuditActionLogout,
models.AuditResourceSession, "", "user logged out")
destroyErr := h.auth.DestroySession(writer, request)
if destroyErr != nil {
h.log.Error("failed to destroy session", "error", destroyErr)

View File

@@ -15,26 +15,15 @@ import (
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/healthcheck"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/audit"
"sneak.berlin/go/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/webhook"
"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.
type Params struct {
fx.In
@@ -48,6 +37,7 @@ type Params struct {
Deploy *deploy.Service
Webhook *webhook.Service
Docker *docker.Client
Audit *audit.Service
}
// Handlers provides HTTP request handlers.
@@ -61,6 +51,7 @@ type Handlers struct {
deploy *deploy.Service
webhook *webhook.Service
docker *docker.Client
audit *audit.Service
globals *globals.Globals
}
@@ -76,10 +67,48 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
deploy: params.Deploy,
webhook: params.Webhook,
docker: params.Docker,
audit: params.Audit,
globals: params.Globals,
}, nil
}
// currentUser returns the currently authenticated user, or nil if not authenticated.
func (h *Handlers) currentUser(request *http.Request) *models.User {
user, err := h.auth.GetCurrentUser(request.Context(), request)
if err != nil || user == nil {
return nil
}
return user
}
// auditLog records an audit entry for the current request.
func (h *Handlers) auditLog(
request *http.Request,
action models.AuditAction,
resourceType models.AuditResourceType,
resourceID string,
detail string,
) {
user := h.currentUser(request)
entry := audit.LogEntry{
Action: action,
ResourceType: resourceType,
ResourceID: resourceID,
Detail: detail,
}
if user != nil {
entry.UserID = user.ID
entry.Username = user.Username
} else {
entry.Username = "anonymous"
}
h.audit.LogFromRequest(request.Context(), request, entry)
}
// addGlobals adds version info and CSRF token to template data map.
func (h *Handlers) addGlobals(
data map[string]any,

View File

@@ -11,6 +11,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
@@ -24,19 +25,16 @@ import (
"sneak.berlin/go/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/healthcheck"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/metrics"
"sneak.berlin/go/upaas/internal/middleware"
"sneak.berlin/go/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/audit"
"sneak.berlin/go/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/notify"
"sneak.berlin/go/upaas/internal/service/webhook"
)
const (
branchMain = "main"
paramSecret = "secret"
)
type testContext struct {
handlers *handlers.Handlers
database *database.Database
@@ -97,7 +95,8 @@ func createAppServices(
logInstance *logger.Logger,
dbInstance *database.Database,
cfg *config.Config,
) (*auth.Service, *app.Service, *deploy.Service, *webhook.Service, *docker.Client) {
metricsInstance *metrics.Metrics,
) (*auth.Service, *app.Service, *deploy.Service, *webhook.Service, *docker.Client, *audit.Service) {
t.Helper()
authSvc, authErr := auth.New(fx.Lifecycle(nil), auth.ServiceParams{
@@ -130,6 +129,7 @@ func createAppServices(
Database: dbInstance,
Docker: dockerClient,
Notify: notifySvc,
Metrics: metricsInstance,
})
require.NoError(t, deployErr)
@@ -137,10 +137,18 @@ func createAppServices(
Logger: logInstance,
Database: dbInstance,
Deploy: deploySvc,
Metrics: metricsInstance,
})
require.NoError(t, webhookErr)
return authSvc, appSvc, deploySvc, webhookSvc, dockerClient
auditSvc, auditErr := audit.New(fx.Lifecycle(nil), audit.ServiceParams{
Logger: logInstance,
Database: dbInstance,
Metrics: metricsInstance,
})
require.NoError(t, auditErr)
return authSvc, appSvc, deploySvc, webhookSvc, dockerClient, auditSvc
}
func setupTestHandlers(t *testing.T) *testContext {
@@ -150,11 +158,14 @@ func setupTestHandlers(t *testing.T) *testContext {
globalInstance, logInstance, dbInstance, hcInstance := createCoreServices(t, cfg)
authSvc, appSvc, deploySvc, webhookSvc, dockerClient := createAppServices(
metricsInstance := metrics.NewForTest(prometheus.NewRegistry())
authSvc, appSvc, deploySvc, webhookSvc, dockerClient, auditSvc := createAppServices(
t,
logInstance,
dbInstance,
cfg,
metricsInstance,
)
handlersInstance, handlerErr := handlers.New(
@@ -169,6 +180,7 @@ func setupTestHandlers(t *testing.T) *testContext {
Deploy: deploySvc,
Webhook: webhookSvc,
Docker: dockerClient,
Audit: auditSvc,
},
)
require.NoError(t, handlerErr)
@@ -178,6 +190,7 @@ func setupTestHandlers(t *testing.T) *testContext {
Globals: globalInstance,
Config: cfg,
Auth: authSvc,
Metrics: metricsInstance,
})
require.NoError(t, mwErr)
@@ -198,8 +211,7 @@ func TestHandleHealthCheck(t *testing.T) {
testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodGet,
"/.well-known/healthcheck.json",
nil,
@@ -216,26 +228,6 @@ func TestHandleHealthCheck(t *testing.T) {
})
}
// assertPageRenders serves a GET request for path with the given
// handler and asserts a 200 response containing want.
func assertPageRenders(
t *testing.T,
handler http.Handler,
path, want string,
) {
t.Helper()
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil,
)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), want)
}
func TestHandleSetupGET(t *testing.T) {
t.Parallel()
@@ -243,7 +235,15 @@ func TestHandleSetupGET(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
assertPageRenders(t, testCtx.handlers.HandleSetupGET(), "/setup", "setup")
request := httptest.NewRequest(http.MethodGet, "/setup", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupGET()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "setup")
})
}
@@ -255,8 +255,7 @@ func createSetupFormRequest(
form.Set("password", password)
form.Set("password_confirm", confirm)
request := httptest.NewRequestWithContext(
context.Background(),
request := httptest.NewRequest(
http.MethodPost,
"/setup",
strings.NewReader(form.Encode()),
@@ -333,7 +332,15 @@ func TestHandleLoginGET(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
assertPageRenders(t, testCtx.handlers.HandleLoginGET(), "/login", "login")
request := httptest.NewRequest(http.MethodGet, "/login", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginGET()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "login")
})
}
@@ -342,8 +349,7 @@ func createLoginFormRequest(username, password string) *http.Request {
form.Set("username", username)
form.Set("password", password)
request := httptest.NewRequestWithContext(
context.Background(),
request := httptest.NewRequest(
http.MethodPost,
"/login",
strings.NewReader(form.Encode()),
@@ -407,9 +413,7 @@ func TestHandleDashboard(t *testing.T) {
testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
request := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard()
@@ -427,9 +431,7 @@ func TestHandleDashboard(t *testing.T) {
// Create an app so the template iterates over AppStats and hits .CSRFField
createTestApp(t, testCtx, "csrf-test-app")
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
request := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard()
@@ -449,9 +451,7 @@ func TestHandleAppNew(t *testing.T) {
testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/apps/new", nil,
)
request := httptest.NewRequest(http.MethodGet, "/apps/new", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleAppNew()
@@ -490,7 +490,7 @@ func createTestApp(
app.CreateAppInput{
Name: name,
RepoURL: "git@example.com:user/" + name + ".git",
Branch: branchMain,
Branch: "main",
},
)
require.NoError(t, err)
@@ -511,7 +511,7 @@ func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
app.CreateAppInput{
Name: "oversize-test-app",
RepoURL: "git@example.com:user/repo.git",
Branch: branchMain,
Branch: "main",
},
)
require.NoError(t, createErr)
@@ -519,15 +519,14 @@ func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
// Create a body larger than 1MB - it should be silently truncated
// and the webhook should still process (or fail gracefully on parse)
largePayload := strings.Repeat("x", 2*1024*1024) // 2MB
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
"/webhook/"+createdApp.WebhookSecret,
strings.NewReader(largePayload),
)
request = addChiURLParams(
request,
map[string]string{paramSecret: createdApp.WebhookSecret},
map[string]string{"secret": createdApp.WebhookSecret},
)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Gitea-Event", "push")
@@ -563,8 +562,7 @@ func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) {
resourceID := cfg.createFn(t, testCtx, app1)
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
cfg.deletePath(app2.ID, resourceID),
nil,
@@ -603,8 +601,7 @@ func TestHandleEnvVarSaveBulk(t *testing.T) {
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(body),
@@ -646,8 +643,7 @@ func TestHandleEnvVarSaveAppNotFound(t *testing.T) {
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
"/apps/nonexistent-id/env",
strings.NewReader(body),
@@ -673,8 +669,7 @@ func TestHandleEnvVarSaveEmptyKeyRejected(t *testing.T) {
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(body),
@@ -696,14 +691,12 @@ func TestHandleEnvVarSaveDuplicateKeyRejected(t *testing.T) {
createdApp := createTestApp(t, testCtx, "envvar-dedup-app")
// Send two entries with the same key — should be rejected
body := `[{"key":"FOO","value":"first"},{"key":"BAR","value":"bar"},` +
`{"key":"FOO","value":"second"}]`
body := `[{"key":"FOO","value":"first"},{"key":"BAR","value":"bar"},{"key":"FOO","value":"second"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(body),
@@ -741,8 +734,7 @@ func TestHandleEnvVarSaveCrossAppIsolation(t *testing.T) {
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+appA.ID+"/env",
strings.NewReader(body),
@@ -805,8 +797,7 @@ func TestHandleEnvVarSaveBodySizeLimit(t *testing.T) {
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(sb.String()),
@@ -875,8 +866,7 @@ func TestDeleteVolumeOwnershipVerification(t *testing.T) {
require.NoError(t, volume.Save(context.Background()))
// Try to delete app1's volume using app2's URL path
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+app2.ID+"/volumes/"+strconv.FormatInt(volume.ID, 10)+"/delete",
nil,
@@ -917,8 +907,7 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
require.NoError(t, port.Save(context.Background()))
// Try to delete app1's port using app2's URL path
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+app2.ID+"/ports/"+strconv.FormatInt(port.ID, 10)+"/delete",
nil,
@@ -959,8 +948,7 @@ func TestHandleEnvVarSaveEmptyClears(t *testing.T) {
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader("[]"),
@@ -1009,8 +997,7 @@ func TestHandleVolumeAddValidatesPaths(t *testing.T) {
form.Set("host_path", tt.hostPath)
form.Set("container_path", tt.containerPath)
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/volumes",
strings.NewReader(form.Encode()),
@@ -1047,8 +1034,7 @@ func TestHandleVolumeAddValidatesPaths(t *testing.T) {
}
// 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) {
t.Parallel()
@@ -1064,21 +1050,13 @@ func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
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 {
t.Run(path, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil,
)
req := httptest.NewRequest(http.MethodGet, path, nil)
rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req)
@@ -1091,9 +1069,7 @@ func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
t.Run("non-exempt redirects", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req)
@@ -1109,8 +1085,7 @@ func TestHandleCancelDeployRedirects(t *testing.T) {
createdApp := createTestApp(t, testCtx, "cancel-deploy-app")
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/deployments/cancel",
nil,
@@ -1130,8 +1105,7 @@ func TestHandleCancelDeployReturns404ForUnknownApp(t *testing.T) {
testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
"/apps/nonexistent/deployments/cancel",
nil,
@@ -1152,16 +1126,12 @@ func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
webhookURL := "/webhook/unknown-secret"
payload := `{"ref": "refs/heads/main"}`
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
webhookURL,
strings.NewReader(payload),
)
request = addChiURLParams(
request,
map[string]string{paramSecret: "unknown-secret"},
)
request = addChiURLParams(request, map[string]string{"secret": "unknown-secret"})
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Gitea-Event", "push")
@@ -1184,22 +1154,21 @@ func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
app.CreateAppInput{
Name: "webhook-test-app",
RepoURL: "git@example.com:user/repo.git",
Branch: branchMain,
Branch: "main",
},
)
require.NoError(t, createErr)
payload := `{"ref": "refs/heads/main", "after": "abc123"}`
webhookURL := "/webhook/" + createdApp.WebhookSecret
request := httptest.NewRequestWithContext(
t.Context(),
request := httptest.NewRequest(
http.MethodPost,
webhookURL,
strings.NewReader(payload),
)
request = addChiURLParams(
request,
map[string]string{paramSecret: createdApp.WebhookSecret},
map[string]string{"secret": createdApp.WebhookSecret},
)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Gitea-Event", "push")

View File

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

View File

@@ -11,17 +11,13 @@ import (
var (
errRepoURLEmpty = errors.New("repository URL must not be empty")
errRepoURLScheme = errors.New("file:// URLs are not allowed for security reasons")
errRepoURLInvalid = errors.New(
"repository URL must use https://, http://, ssh://, git://, " +
"or git@host:path format",
)
errRepoURLNoHost = errors.New("repository URL must include a host")
errRepoURLNoPath = errors.New("repository URL must include a path")
errRepoURLInvalid = errors.New("repository URL must use https://, http://, ssh://, git://, or git@host:path format")
errRepoURLNoHost = errors.New("repository URL must include a host")
errRepoURLNoPath = errors.New("repository URL must include a path")
)
// scpLikeRepoRe matches SCP-like git URLs: git@host:path
// (e.g. git@github.com:user/repo.git). Only the "git" user is allowed,
// as that is the standard for SSH deploy keys.
// scpLikeRepoRe matches SCP-like git URLs: git@host:path (e.g. git@github.com:user/repo.git).
// Only the "git" user is allowed, as that is the standard for SSH deploy keys.
var scpLikeRepoRe = regexp.MustCompile(`^git@[a-zA-Z0-9._-]+:.+$`)
// allowedRepoSchemes lists the URL schemes accepted for repository URLs.
@@ -34,8 +30,7 @@ var allowedRepoSchemes = map[string]bool{
"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 {
if strings.TrimSpace(repoURL) == "" {
return errRepoURLEmpty

View File

@@ -22,11 +22,7 @@ func TestValidateRepoURL(t *testing.T) {
{name: "SCP-like URL", url: "git@github.com:user/repo.git", wantErr: false},
{name: "SCP-like with dots", url: "git@git.example.com:org/repo.git", wantErr: false},
{name: "https without .git", url: "https://github.com/user/repo", wantErr: false},
{
name: "https with port",
url: "https://git.example.com:8443/user/repo.git",
wantErr: false,
},
{name: "https with port", url: "https://git.example.com:8443/user/repo.git", wantErr: false},
// Invalid URLs
{name: "empty string", url: "", wantErr: true},
@@ -41,22 +37,10 @@ func TestValidateRepoURL(t *testing.T) {
{name: "no path https", url: "https://github.com", wantErr: true},
{name: "no path https trailing slash", url: "https://github.com/", wantErr: true},
{name: "SCP-like non-git user", url: "root@github.com:user/repo.git", wantErr: true},
{
name: "SCP-like arbitrary user",
url: "admin@github.com:user/repo.git",
wantErr: true,
},
{name: "SCP-like arbitrary user", url: "admin@github.com:user/repo.git", wantErr: true},
{name: "path traversal SCP", url: "git@github.com:../../etc/passwd", wantErr: true},
{
name: "path traversal https",
url: "https://github.com/user/../../../etc/passwd",
wantErr: true,
},
{
name: "path traversal in middle",
url: "https://github.com/user/repo/../secret",
wantErr: true,
},
{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 {

View File

@@ -5,11 +5,8 @@ import (
"strings"
)
// ansiEscapePattern matches ANSI escape sequences (CSI, OSC, and
// single-character escapes).
var ansiEscapePattern = regexp.MustCompile(
`(\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]])`,
)
// ansiEscapePattern matches ANSI escape sequences (CSI, OSC, and single-character escapes).
var ansiEscapePattern = regexp.MustCompile(`(\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]])`)
// SanitizeLogs strips ANSI escape sequences and non-printable control characters
// from container log output. Newlines (\n), carriage returns (\r), and tabs (\t)

View File

@@ -6,7 +6,7 @@ import (
"sneak.berlin/go/upaas/internal/handlers"
)
func TestSanitizeLogs(t *testing.T) {
func TestSanitizeLogs(t *testing.T) { //nolint:funlen // table-driven tests
t.Parallel()
tests := []struct {

View File

@@ -3,6 +3,7 @@ package handlers
import (
"net/http"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/templates"
)
@@ -55,8 +56,8 @@ func (h *Handlers) renderSetupError(
errorMsg string,
) {
data := h.addGlobals(map[string]any{
"Username": username,
dataKeyError: errorMsg,
"Username": username,
"Error": errorMsg,
}, request)
h.renderTemplate(writer, tmpl, "setup.html", data)
}
@@ -111,6 +112,9 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
return
}
h.auditLog(request, models.AuditActionSetup,
models.AuditResourceUser, "", "initial setup completed")
http.Redirect(writer, request, "/", http.StatusSeeOther)
}
}

View File

@@ -7,15 +7,14 @@ import (
"github.com/go-chi/chi/v5"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/webhook"
"sneak.berlin/go/upaas/internal/service/audit"
)
// maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB).
const maxWebhookBodySize = 1 << 20
// HandleWebhook handles incoming webhooks from Gitea, GitHub, or GitLab.
// The webhook source is auto-detected from HTTP headers.
func (h *Handlers) HandleWebhook() http.HandlerFunc {
// HandleWebhook handles incoming Gitea webhooks.
func (h *Handlers) HandleWebhook() http.HandlerFunc { //nolint:funlen // audit logging adds necessary length
return func(writer http.ResponseWriter, request *http.Request) {
secret := chi.URLParam(request, "secret")
if secret == "" {
@@ -52,17 +51,25 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
return
}
// Auto-detect webhook source from headers
source := webhook.DetectWebhookSource(request.Header)
// Get event type from header
eventType := request.Header.Get("X-Gitea-Event")
if eventType == "" {
eventType = "push"
}
// Extract event type based on detected source
eventType := webhook.DetectEventType(request.Header, source)
// Log webhook receipt
h.audit.LogFromRequest(request.Context(), request, audit.LogEntry{
Username: "webhook",
Action: models.AuditActionWebhookReceive,
ResourceType: models.AuditResourceWebhook,
ResourceID: application.ID,
Detail: "webhook from app: " + application.Name + ", event: " + eventType,
})
// Process webhook
webhookErr := h.webhook.HandleWebhook(
request.Context(),
application,
source,
eventType,
body,
)

View File

@@ -47,8 +47,8 @@ func (h *Handlers) HandleAppWebhookEvents() http.HandlerFunc {
}
data := h.addGlobals(map[string]any{
dataKeyApp: application,
"Events": events,
"App": application,
"Events": events,
}, request)
h.renderTemplate(writer, tmpl, "webhook_events.html", data)

148
internal/metrics/metrics.go Normal file
View File

@@ -0,0 +1,148 @@
// Package metrics provides Prometheus metrics for upaas.
//
//nolint:revive // "metrics" matches the domain; runtime/metrics is rarely imported directly
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.uber.org/fx"
)
// Params contains dependencies for Metrics.
type Params struct {
fx.In
}
// Metrics holds all Prometheus metrics for the application.
type Metrics struct {
// Deployment metrics.
DeploymentsTotal *prometheus.CounterVec
DeploymentDuration *prometheus.HistogramVec
DeploymentsInFlight *prometheus.GaugeVec
// Container health metrics.
ContainerHealthy *prometheus.GaugeVec
// Webhook metrics.
WebhookEventsTotal *prometheus.CounterVec
// HTTP request metrics.
HTTPRequestsTotal *prometheus.CounterVec
HTTPRequestDuration *prometheus.HistogramVec
HTTPResponseSizeBytes *prometheus.HistogramVec
// Audit log metrics.
AuditEventsTotal *prometheus.CounterVec
}
// New creates a new Metrics instance with all Prometheus metrics registered
// in the default Prometheus registry.
func New(_ fx.Lifecycle, _ Params) (*Metrics, error) {
return newMetrics(promauto.With(prometheus.DefaultRegisterer)), nil
}
// NewForTest creates a Metrics instance with a custom registry for test isolation.
func NewForTest(reg prometheus.Registerer) *Metrics {
return newMetrics(promauto.With(reg))
}
// newMetrics creates a Metrics instance using the given factory.
func newMetrics(factory promauto.Factory) *Metrics {
return &Metrics{
DeploymentsTotal: newDeploymentsTotal(factory),
DeploymentDuration: newDeploymentDuration(factory),
DeploymentsInFlight: newDeploymentsInFlight(factory),
ContainerHealthy: newContainerHealthy(factory),
WebhookEventsTotal: newWebhookEventsTotal(factory),
HTTPRequestsTotal: newHTTPRequestsTotal(factory),
HTTPRequestDuration: newHTTPRequestDuration(factory),
HTTPResponseSizeBytes: newHTTPResponseSizeBytes(factory),
AuditEventsTotal: newAuditEventsTotal(factory),
}
}
func newDeploymentsTotal(f promauto.Factory) *prometheus.CounterVec {
return f.NewCounterVec(prometheus.CounterOpts{
Namespace: "upaas",
Subsystem: "deployments",
Name: "total",
Help: "Total number of deployments by app and status.",
}, []string{"app", "status"})
}
func newDeploymentDuration(f promauto.Factory) *prometheus.HistogramVec {
return f.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "upaas",
Subsystem: "deployments",
Name: "duration_seconds",
Help: "Duration of deployments in seconds by app and status.",
Buckets: []float64{10, 30, 60, 120, 300, 600, 1800},
}, []string{"app", "status"})
}
func newDeploymentsInFlight(f promauto.Factory) *prometheus.GaugeVec {
return f.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "upaas",
Subsystem: "deployments",
Name: "in_flight",
Help: "Number of deployments currently in progress by app.",
}, []string{"app"})
}
func newContainerHealthy(f promauto.Factory) *prometheus.GaugeVec {
return f.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "upaas",
Subsystem: "container",
Name: "healthy",
Help: "Whether the app container is healthy (1) or unhealthy (0).",
}, []string{"app"})
}
func newWebhookEventsTotal(f promauto.Factory) *prometheus.CounterVec {
return f.NewCounterVec(prometheus.CounterOpts{
Namespace: "upaas",
Subsystem: "webhook",
Name: "events_total",
Help: "Total number of webhook events by app, event type, and matched status.",
}, []string{"app", "event_type", "matched"})
}
func newHTTPRequestsTotal(f promauto.Factory) *prometheus.CounterVec {
return f.NewCounterVec(prometheus.CounterOpts{
Namespace: "upaas",
Subsystem: "http",
Name: "requests_total",
Help: "Total number of HTTP requests by method and status code.",
}, []string{"method", "status_code"})
}
func newHTTPRequestDuration(f promauto.Factory) *prometheus.HistogramVec {
return f.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "upaas",
Subsystem: "http",
Name: "request_duration_seconds",
Help: "Duration of HTTP requests in seconds by method.",
Buckets: prometheus.DefBuckets,
}, []string{"method"})
}
//nolint:mnd // bucket boundaries are domain-specific constants
func newHTTPResponseSizeBytes(f promauto.Factory) *prometheus.HistogramVec {
return f.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "upaas",
Subsystem: "http",
Name: "response_size_bytes",
Help: "Size of HTTP responses in bytes by method.",
Buckets: prometheus.ExponentialBuckets(100, 10, 7),
}, []string{"method"})
}
func newAuditEventsTotal(f promauto.Factory) *prometheus.CounterVec {
return f.NewCounterVec(prometheus.CounterOpts{
Namespace: "upaas",
Subsystem: "audit",
Name: "events_total",
Help: "Total number of audit log events by action.",
}, []string{"action"})
}

View File

@@ -0,0 +1,158 @@
package metrics_test
import (
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"sneak.berlin/go/upaas/internal/metrics"
)
func TestNewForTest(t *testing.T) {
t.Parallel()
reg := prometheus.NewRegistry()
m := metrics.NewForTest(reg)
require.NotNil(t, m)
assert.NotNil(t, m.DeploymentsTotal)
assert.NotNil(t, m.DeploymentDuration)
assert.NotNil(t, m.DeploymentsInFlight)
assert.NotNil(t, m.ContainerHealthy)
assert.NotNil(t, m.WebhookEventsTotal)
assert.NotNil(t, m.HTTPRequestsTotal)
assert.NotNil(t, m.HTTPRequestDuration)
assert.NotNil(t, m.HTTPResponseSizeBytes)
assert.NotNil(t, m.AuditEventsTotal)
}
func TestNew(t *testing.T) {
t.Parallel()
m, err := metrics.New(fx.Lifecycle(nil), metrics.Params{})
require.NoError(t, err)
require.NotNil(t, m)
}
func TestDeploymentMetrics(t *testing.T) {
t.Parallel()
reg := prometheus.NewRegistry()
m := metrics.NewForTest(reg)
m.DeploymentsTotal.WithLabelValues("test-app", "success").Inc()
m.DeploymentDuration.WithLabelValues("test-app", "success").Observe(42.5)
m.DeploymentsInFlight.WithLabelValues("test-app").Set(1)
families, err := reg.Gather()
require.NoError(t, err)
names := make(map[string]bool)
for _, f := range families {
names[f.GetName()] = true
}
assert.True(t, names["upaas_deployments_total"])
assert.True(t, names["upaas_deployments_duration_seconds"])
assert.True(t, names["upaas_deployments_in_flight"])
}
func TestContainerHealthMetrics(t *testing.T) {
t.Parallel()
reg := prometheus.NewRegistry()
m := metrics.NewForTest(reg)
m.ContainerHealthy.WithLabelValues("my-app").Set(1)
families, err := reg.Gather()
require.NoError(t, err)
found := false
for _, f := range families {
if f.GetName() == "upaas_container_healthy" {
found = true
break
}
}
assert.True(t, found)
}
func TestWebhookMetrics(t *testing.T) {
t.Parallel()
reg := prometheus.NewRegistry()
m := metrics.NewForTest(reg)
m.WebhookEventsTotal.WithLabelValues("test-app", "push", "true").Inc()
families, err := reg.Gather()
require.NoError(t, err)
found := false
for _, f := range families {
if f.GetName() == "upaas_webhook_events_total" {
found = true
break
}
}
assert.True(t, found)
}
func TestHTTPMetrics(t *testing.T) {
t.Parallel()
reg := prometheus.NewRegistry()
m := metrics.NewForTest(reg)
m.HTTPRequestsTotal.WithLabelValues("GET", "200").Inc()
m.HTTPRequestDuration.WithLabelValues("GET").Observe(0.05)
m.HTTPResponseSizeBytes.WithLabelValues("GET").Observe(1024)
families, err := reg.Gather()
require.NoError(t, err)
names := make(map[string]bool)
for _, f := range families {
names[f.GetName()] = true
}
assert.True(t, names["upaas_http_requests_total"])
assert.True(t, names["upaas_http_request_duration_seconds"])
assert.True(t, names["upaas_http_response_size_bytes"])
}
func TestAuditMetrics(t *testing.T) {
t.Parallel()
reg := prometheus.NewRegistry()
m := metrics.NewForTest(reg)
m.AuditEventsTotal.WithLabelValues("login").Inc()
families, err := reg.Gather()
require.NoError(t, err)
found := false
for _, f := range families {
if f.GetName() == "upaas_audit_events_total" {
found = true
break
}
}
assert.True(t, found)
}

View File

@@ -24,30 +24,21 @@ func newCORSTestMiddleware(corsOrigins string) *Middleware {
}
}
// assertNoCORSHeaders runs a request with the given Origin header through
// CORS middleware configured with corsOrigins and asserts that no
// Access-Control-Allow-Origin header is set.
func assertNoCORSHeaders(t *testing.T, corsOrigins, origin, msg string) {
t.Helper()
func TestCORS_NoOriginsConfigured_NoCORSHeaders(t *testing.T) {
t.Parallel()
m := newCORSTestMiddleware(corsOrigins)
m := newCORSTestMiddleware("")
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.Header.Set("Origin", origin)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://evil.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"), msg)
}
func TestCORS_NoOriginsConfigured_NoCORSHeaders(t *testing.T) {
t.Parallel()
assertNoCORSHeaders(t, "", "https://evil.com",
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"),
"expected no CORS headers when no origins configured")
}
@@ -59,7 +50,7 @@ func TestCORS_OriginsConfigured_AllowsMatchingOrigin(t *testing.T) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://app.example.com")
rec := httptest.NewRecorder()
@@ -74,6 +65,17 @@ func TestCORS_OriginsConfigured_AllowsMatchingOrigin(t *testing.T) {
func TestCORS_OriginsConfigured_RejectsNonMatchingOrigin(t *testing.T) {
t.Parallel()
assertNoCORSHeaders(t, "https://app.example.com", "https://evil.com",
m := newCORSTestMiddleware("https://app.example.com")
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://evil.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"),
"expected no CORS headers for non-matching origin")
}

View File

@@ -21,6 +21,7 @@ import (
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/metrics"
"sneak.berlin/go/upaas/internal/service/auth"
)
@@ -35,33 +36,37 @@ type Params struct {
Globals *globals.Globals
Config *config.Config
Auth *auth.Service
Metrics *metrics.Metrics
}
// Middleware provides HTTP middleware.
type Middleware struct {
log *slog.Logger
params *Params
log *slog.Logger
metrics *metrics.Metrics
params *Params
}
// New creates a new Middleware instance.
func New(_ fx.Lifecycle, params Params) (*Middleware, error) {
return &Middleware{
log: params.Logger.Get(),
params: &params,
log: params.Logger.Get(),
metrics: params.Metrics,
params: &params,
}, nil
}
// loggingResponseWriter wraps http.ResponseWriter to capture status code.
// loggingResponseWriter wraps http.ResponseWriter to capture status code and bytes written.
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
statusCode int
bytesWritten int
}
func newLoggingResponseWriter(
writer http.ResponseWriter,
) *loggingResponseWriter {
return &loggingResponseWriter{writer, http.StatusOK}
return &loggingResponseWriter{ResponseWriter: writer, statusCode: http.StatusOK}
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
@@ -69,7 +74,14 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.ResponseWriter.WriteHeader(code)
}
// Logging returns a request logging middleware.
func (lrw *loggingResponseWriter) Write(b []byte) (int, error) {
n, err := lrw.ResponseWriter.Write(b)
lrw.bytesWritten += n
return n, err
}
// Logging returns a request logging middleware that also records HTTP metrics.
func (m *Middleware) Logging() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(
@@ -83,6 +95,8 @@ func (m *Middleware) Logging() func(http.Handler) http.Handler {
defer func() {
latency := time.Since(start)
reqID := middleware.GetReqID(ctx)
statusStr := strconv.Itoa(lrw.statusCode)
m.log.InfoContext(ctx, "request",
"request_start", start,
"method", request.Method,
@@ -91,10 +105,21 @@ func (m *Middleware) Logging() func(http.Handler) http.Handler {
"request_id", reqID,
"referer", request.Referer(),
"proto", request.Proto,
"remoteIP", realIP(request),
"remoteIP", RealIP(request),
"status", lrw.statusCode,
"bytes", lrw.bytesWritten,
"latency_ms", latency.Milliseconds(),
)
m.metrics.HTTPRequestsTotal.WithLabelValues(
request.Method, statusStr,
).Inc()
m.metrics.HTTPRequestDuration.WithLabelValues(
request.Method,
).Observe(latency.Seconds())
m.metrics.HTTPResponseSizeBytes.WithLabelValues(
request.Method,
).Observe(float64(lrw.bytesWritten))
}()
next.ServeHTTP(lrw, request)
@@ -145,11 +170,11 @@ func isTrustedProxy(ip net.IP) bool {
return false
}
// realIP extracts the client's real IP address from the request.
// RealIP extracts the client's real IP address from the request.
// Proxy headers (X-Real-IP, X-Forwarded-For) are only trusted when the
// direct connection originates from an RFC1918/loopback address.
// Otherwise, headers are ignored and RemoteAddr is used (fail closed).
func realIP(r *http.Request) string {
func RealIP(r *http.Request) string {
addr := ipFromHostPort(r.RemoteAddr)
remoteIP := net.ParseIP(addr)
@@ -340,7 +365,7 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
writer http.ResponseWriter,
request *http.Request,
) {
ip := realIP(request)
ip := RealIP(request)
limiter := loginLimiter.getLimiter(ip)
if !limiter.Allow() {
@@ -370,9 +395,8 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
}
}
// APISessionAuth returns middleware that requires session authentication
// for API routes. Unlike SessionAuth, it returns JSON 401 responses instead
// of redirecting to /login.
// APISessionAuth returns middleware that requires session authentication for API routes.
// Unlike SessionAuth, it returns JSON 401 responses instead of redirecting to /login.
func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(

View File

@@ -30,15 +30,13 @@ func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
mw := newTestMiddleware(t)
handler := mw.LoginRateLimit()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
))
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// First 5 requests should succeed (burst)
for i := range 5 {
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.168.1.1:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -46,12 +44,11 @@ func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
}
// 6th request should be rate limited
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.168.1.1:12345"
rec := httptest.NewRecorder()
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
@@ -60,29 +57,27 @@ func TestLoginRateLimitIsolatesIPs(t *testing.T) {
mw := newTestMiddleware(t)
handler := mw.LoginRateLimit()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
))
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Exhaust IP1's budget
for range 5 {
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req.RemoteAddr = testProxyAddr
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "10.0.0.1:1234"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
}
// IP1 should be blocked
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req.RemoteAddr = testProxyAddr
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "10.0.0.1:1234"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
// IP2 should still work
req2 := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req2 := httptest.NewRequest(http.MethodPost, "/login", nil)
req2.RemoteAddr = "10.0.0.2:1234"
rec2 := httptest.NewRecorder()
handler.ServeHTTP(rec2, req2)
@@ -95,28 +90,25 @@ func TestLoginRateLimitReturns429Body(t *testing.T) {
mw := newTestMiddleware(t)
handler := mw.LoginRateLimit()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
))
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Exhaust burst
for range 5 {
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "172.16.0.1:5555"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "172.16.0.1:5555"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
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) {

View File

@@ -1,4 +1,4 @@
package middleware //nolint:testpackage // tests unexported realIP function
package middleware //nolint:testpackage // tests RealIP via internal package access
import (
"context"
@@ -7,16 +7,6 @@ import (
"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
t.Parallel()
@@ -30,63 +20,63 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
// === Trusted proxy (RFC1918 / loopback) — headers ARE honoured ===
{
name: "trusted: X-Real-IP from 10.x",
remoteAddr: testProxyAddr,
xRealIP: testRealIP,
remoteAddr: "10.0.0.1:1234",
xRealIP: "203.0.113.5",
xff: "198.51.100.1, 10.0.0.1",
want: testRealIP,
want: "203.0.113.5",
},
{
name: "trusted: XFF from 10.x when no X-Real-IP",
remoteAddr: testProxyAddr,
remoteAddr: "10.0.0.1:1234",
xff: "198.51.100.1, 10.0.0.1",
want: testXFFIP,
want: "198.51.100.1",
},
{
name: "trusted: XFF single IP from 10.x",
remoteAddr: testProxyAddr,
remoteAddr: "10.0.0.1:1234",
xff: "203.0.113.10",
want: "203.0.113.10",
},
{
name: "trusted: falls back to RemoteAddr (192.168.x)",
remoteAddr: "192.168.1.1:5678",
want: testPrivateIP,
want: "192.168.1.1",
},
{
name: "trusted: RemoteAddr without port",
remoteAddr: testPrivateIP,
want: testPrivateIP,
remoteAddr: "192.168.1.1",
want: "192.168.1.1",
},
{
name: "trusted: X-Real-IP with whitespace from 10.x",
remoteAddr: testProxyAddr,
remoteAddr: "10.0.0.1:1234",
xRealIP: " 203.0.113.5 ",
want: testRealIP,
want: "203.0.113.5",
},
{
name: "trusted: XFF with whitespace from 10.x",
remoteAddr: testProxyAddr,
remoteAddr: "10.0.0.1:1234",
xff: " 198.51.100.1 , 10.0.0.1",
want: testXFFIP,
want: "198.51.100.1",
},
{
name: "trusted: empty X-Real-IP falls through to XFF from 10.x",
remoteAddr: testProxyAddr,
remoteAddr: "10.0.0.1:1234",
xRealIP: " ",
xff: testXFFIP,
want: testXFFIP,
xff: "198.51.100.1",
want: "198.51.100.1",
},
{
name: "trusted: loopback honours X-Real-IP",
remoteAddr: "127.0.0.1:9999",
xRealIP: testPublicIP,
want: testPublicIP,
xRealIP: "93.184.216.34",
want: "93.184.216.34",
},
{
name: "trusted: 172.16.x honours XFF",
remoteAddr: "172.16.0.1:4321",
xff: testPublicDNSIP,
want: testPublicDNSIP,
xff: "8.8.8.8",
want: "8.8.8.8",
},
// === Untrusted proxy (public IP) — headers IGNORED, use RemoteAddr ===
@@ -107,17 +97,17 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
remoteAddr: "8.8.8.8:443",
xRealIP: "1.2.3.4",
xff: "5.6.7.8",
want: testPublicDNSIP,
want: "8.8.8.8",
},
{
name: "untrusted: no headers, public RemoteAddr",
remoteAddr: "93.184.216.34:8080",
want: testPublicIP,
want: "93.184.216.34",
},
{
name: "untrusted: public RemoteAddr without port",
remoteAddr: testPublicIP,
want: testPublicIP,
remoteAddr: "93.184.216.34",
want: "93.184.216.34",
},
}
@@ -136,9 +126,9 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
req.Header.Set("X-Forwarded-For", tt.xff)
}
got := realIP(req)
got := RealIP(req)
if got != tt.want {
t.Errorf("realIP() = %q, want %q", got, tt.want)
t.Errorf("RealIP() = %q, want %q", got, tt.want)
}
})
}
@@ -149,9 +139,7 @@ func TestIsTrustedProxy(t *testing.T) {
trusted := []string{"10.0.0.1", "10.255.255.255", "172.16.0.1", "172.31.255.255",
"192.168.0.1", "192.168.255.255", "127.0.0.1", "127.255.255.255", "::1"}
untrusted := []string{
testPublicDNSIP, "203.0.113.1", "172.32.0.1", "11.0.0.1", "2001:db8::1",
}
untrusted := []string{"8.8.8.8", "203.0.113.1", "172.32.0.1", "11.0.0.1", "2001:db8::1"}
for _, addr := range trusted {
ip := net.ParseIP(addr)

View File

@@ -0,0 +1,193 @@
package models
import (
"context"
"database/sql"
"fmt"
"time"
"sneak.berlin/go/upaas/internal/database"
)
// AuditAction represents the type of audited user action.
type AuditAction string
// Audit action constants.
const (
AuditActionLogin AuditAction = "login"
AuditActionLogout AuditAction = "logout"
AuditActionAppCreate AuditAction = "app.create"
AuditActionAppUpdate AuditAction = "app.update"
AuditActionAppDelete AuditAction = "app.delete"
AuditActionAppDeploy AuditAction = "app.deploy"
AuditActionAppRollback AuditAction = "app.rollback"
AuditActionAppRestart AuditAction = "app.restart"
AuditActionAppStop AuditAction = "app.stop"
AuditActionAppStart AuditAction = "app.start"
AuditActionDeployCancel AuditAction = "deploy.cancel"
AuditActionEnvVarSave AuditAction = "env_var.save"
AuditActionLabelAdd AuditAction = "label.add"
AuditActionLabelEdit AuditAction = "label.edit"
AuditActionLabelDelete AuditAction = "label.delete"
AuditActionVolumeAdd AuditAction = "volume.add"
AuditActionVolumeEdit AuditAction = "volume.edit"
AuditActionVolumeDelete AuditAction = "volume.delete"
AuditActionPortAdd AuditAction = "port.add"
AuditActionPortDelete AuditAction = "port.delete"
AuditActionSetup AuditAction = "setup"
AuditActionWebhookReceive AuditAction = "webhook.receive"
)
// AuditResourceType represents the type of resource being acted on.
type AuditResourceType string
// Audit resource type constants.
const (
AuditResourceApp AuditResourceType = "app"
AuditResourceUser AuditResourceType = "user"
AuditResourceSession AuditResourceType = "session"
AuditResourceEnvVar AuditResourceType = "env_var"
AuditResourceLabel AuditResourceType = "label"
AuditResourceVolume AuditResourceType = "volume"
AuditResourcePort AuditResourceType = "port"
AuditResourceDeployment AuditResourceType = "deployment"
AuditResourceWebhook AuditResourceType = "webhook"
)
// AuditEntry represents a single audit log entry.
type AuditEntry struct {
db *database.Database
ID int64
UserID sql.NullInt64
Username string
Action AuditAction
ResourceType AuditResourceType
ResourceID sql.NullString
Detail sql.NullString
RemoteIP sql.NullString
CreatedAt time.Time
}
// NewAuditEntry creates a new AuditEntry with a database reference.
func NewAuditEntry(db *database.Database) *AuditEntry {
return &AuditEntry{db: db}
}
// Save inserts the audit entry into the database.
func (a *AuditEntry) Save(ctx context.Context) error {
query := `
INSERT INTO audit_log (
user_id, username, action, resource_type, resource_id,
detail, remote_ip
) VALUES (?, ?, ?, ?, ?, ?, ?)`
result, err := a.db.Exec(ctx, query,
a.UserID, a.Username, a.Action, a.ResourceType,
a.ResourceID, a.Detail, a.RemoteIP,
)
if err != nil {
return fmt.Errorf("inserting audit entry: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("getting audit entry id: %w", err)
}
a.ID = id
return nil
}
// FindAuditEntries returns recent audit log entries, newest first.
func FindAuditEntries(
ctx context.Context,
db *database.Database,
limit int,
) ([]*AuditEntry, error) {
query := `
SELECT id, user_id, username, action, resource_type, resource_id,
detail, remote_ip, created_at
FROM audit_log
ORDER BY created_at DESC
LIMIT ?`
rows, err := db.Query(ctx, query, limit)
if err != nil {
return nil, fmt.Errorf("querying audit entries: %w", err)
}
defer func() { _ = rows.Close() }()
return scanAuditRows(rows)
}
// FindAuditEntriesByResource returns audit log entries for a specific resource.
func FindAuditEntriesByResource(
ctx context.Context,
db *database.Database,
resourceType AuditResourceType,
resourceID string,
limit int,
) ([]*AuditEntry, error) {
query := `
SELECT id, user_id, username, action, resource_type, resource_id,
detail, remote_ip, created_at
FROM audit_log
WHERE resource_type = ? AND resource_id = ?
ORDER BY created_at DESC
LIMIT ?`
rows, err := db.Query(ctx, query, resourceType, resourceID, limit)
if err != nil {
return nil, fmt.Errorf("querying audit entries by resource: %w", err)
}
defer func() { _ = rows.Close() }()
return scanAuditRows(rows)
}
// CountAuditEntries returns the total number of audit log entries.
func CountAuditEntries(
ctx context.Context,
db *database.Database,
) (int, error) {
var count int
row := db.QueryRow(ctx, "SELECT COUNT(*) FROM audit_log")
err := row.Scan(&count)
if err != nil {
return 0, fmt.Errorf("counting audit entries: %w", err)
}
return count, nil
}
func scanAuditRows(rows *sql.Rows) ([]*AuditEntry, error) {
var entries []*AuditEntry
for rows.Next() {
entry := &AuditEntry{}
scanErr := rows.Scan(
&entry.ID, &entry.UserID, &entry.Username, &entry.Action,
&entry.ResourceType, &entry.ResourceID, &entry.Detail,
&entry.RemoteIP, &entry.CreatedAt,
)
if scanErr != nil {
return nil, fmt.Errorf("scanning audit entry: %w", scanErr)
}
entries = append(entries, entry)
}
rowsErr := rows.Err()
if rowsErr != nil {
return nil, fmt.Errorf("iterating audit entries: %w", rowsErr)
}
return entries, nil
}

View File

@@ -93,41 +93,6 @@ func FindEnvVar(
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.
func FindEnvVarsByAppID(
ctx context.Context,
@@ -138,7 +103,29 @@ func FindEnvVarsByAppID(
SELECT id, app_id, key, value FROM app_env_vars
WHERE app_id = ? ORDER BY key`
return findAllByAppID(ctx, db, query, appID, "env vars", NewEnvVar)
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying env vars by app: %w", err)
}
defer func() { _ = rows.Close() }()
var envVars []*EnvVar
for rows.Next() {
envVar := NewEnvVar(db)
scanErr := rows.Scan(
&envVar.ID, &envVar.AppID, &envVar.Key, &envVar.Value,
)
if scanErr != nil {
return nil, scanErr
}
envVars = append(envVars, envVar)
}
return envVars, rows.Err()
}
// EnvVarPair is a key-value pair for bulk env var operations.

View File

@@ -93,10 +93,6 @@ func FindLabel(
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.
func FindLabelsByAppID(
ctx context.Context,
@@ -107,7 +103,27 @@ func FindLabelsByAppID(
SELECT id, app_id, key, value FROM app_labels
WHERE app_id = ? ORDER BY key`
return findAllByAppID(ctx, db, query, appID, "labels", NewLabel)
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying labels by app: %w", err)
}
defer func() { _ = rows.Close() }()
var labels []*Label
for rows.Next() {
label := NewLabel(db)
scanErr := rows.Scan(&label.ID, &label.AppID, &label.Key, &label.Value)
if scanErr != nil {
return nil, scanErr
}
labels = append(labels, label)
}
return labels, rows.Err()
}
// DeleteLabelsByAppID deletes all labels for an app.

View File

@@ -23,6 +23,7 @@ const (
testBranch = "main"
testValue = "value"
testEventType = "push"
testAdmin = "admin"
)
func setupTestDB(t *testing.T) (*database.Database, func()) {
@@ -183,7 +184,7 @@ func TestUserExists(t *testing.T) {
defer cleanup()
user := models.NewUser(testDB)
user.Username = "admin"
user.Username = testAdmin
user.PasswordHash = testHash
err := user.Save(context.Background())
@@ -317,54 +318,33 @@ func TestAllApps(t *testing.T) {
// EnvVar Tests.
// testKVCreateAndFind exercises the create-and-find round trip shared
// by key-value models (env vars, labels).
func testKVCreateAndFind[T any](
t *testing.T,
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)
defer cleanup()
// Create app first.
app := createTestApp(t, testDB)
id, err := create(testDB, 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.Value = "postgres://localhost/db"
err := envVar.Save(context.Background())
return envVar.ID, err
}
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 },
testDB, cleanup := setupTestDB(t)
defer cleanup()
// Create app first.
app := createTestApp(t, testDB)
envVar := models.NewEnvVar(testDB)
envVar.AppID = app.ID
envVar.Key = "DATABASE_URL"
envVar.Value = "postgres://localhost/db"
err := envVar.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, envVar.ID)
envVars, err := models.FindEnvVarsByAppID(
context.Background(), testDB, app.ID,
)
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) {
@@ -396,27 +376,32 @@ func TestEnvVarCRUD(t *testing.T) {
// 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) {
t.Parallel()
t.Run("creates and finds labels", func(t *testing.T) {
t.Parallel()
testKVCreateAndFind(t, "traefik.enable", saveTestLabel,
models.FindLabelsByAppID,
func(l *models.Label) string { return l.Key },
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
label := models.NewLabel(testDB)
label.AppID = app.ID
label.Key = "traefik.enable"
label.Value = "true"
err := label.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, label.ID)
labels, err := models.FindLabelsByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
require.Len(t, labels, 1)
assert.Equal(t, "traefik.enable", labels[0].Key)
})
}
@@ -585,9 +570,7 @@ func TestDeploymentFindByAppID(t *testing.T) {
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)
assert.Len(t, deployments, 3)
}
@@ -724,6 +707,7 @@ func TestAppGetWebhookEvents(t *testing.T) {
// Cascade Delete Tests.
//nolint:funlen // Test function with many assertions - acceptable for integration tests
func TestCascadeDelete(t *testing.T) {
t.Parallel()
@@ -800,8 +784,7 @@ func TestCascadeDelete(t *testing.T) {
// Resource Limits Tests.
//nolint:funlen // integration test with multiple subtests
func TestAppResourceLimits(t *testing.T) {
func TestAppResourceLimits(t *testing.T) { //nolint:funlen // integration test with multiple subtests
t.Parallel()
t.Run("saves and loads CPU limit", func(t *testing.T) {
@@ -889,6 +872,179 @@ func TestAppResourceLimits(t *testing.T) {
})
}
// AuditEntry Tests.
func TestAuditEntryCreateAndFind(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
entry := models.NewAuditEntry(testDB)
entry.Username = testAdmin
entry.Action = models.AuditActionLogin
entry.ResourceType = models.AuditResourceSession
err := entry.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, entry.ID)
entries, err := models.FindAuditEntries(context.Background(), testDB, 10)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, testAdmin, entries[0].Username)
assert.Equal(t, models.AuditActionLogin, entries[0].Action)
assert.Equal(t, models.AuditResourceSession, entries[0].ResourceType)
}
func TestAuditEntryWithAllFields(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
entry := models.NewAuditEntry(testDB)
entry.UserID = sql.NullInt64{Int64: 1, Valid: true}
entry.Username = testAdmin
entry.Action = models.AuditActionAppCreate
entry.ResourceType = models.AuditResourceApp
entry.ResourceID = sql.NullString{String: "app-123", Valid: true}
entry.Detail = sql.NullString{String: "created new app", Valid: true}
entry.RemoteIP = sql.NullString{String: "192.168.1.1", Valid: true}
err := entry.Save(context.Background())
require.NoError(t, err)
entries, err := models.FindAuditEntries(context.Background(), testDB, 10)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, int64(1), entries[0].UserID.Int64)
assert.Equal(t, "app-123", entries[0].ResourceID.String)
assert.Equal(t, "created new app", entries[0].Detail.String)
assert.Equal(t, "192.168.1.1", entries[0].RemoteIP.String)
}
func TestAuditEntryFindByResource(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
// Create entries for different resources.
for _, action := range []models.AuditAction{
models.AuditActionAppCreate,
models.AuditActionAppUpdate,
models.AuditActionAppDeploy,
} {
entry := models.NewAuditEntry(testDB)
entry.Username = testAdmin
entry.Action = action
entry.ResourceType = models.AuditResourceApp
entry.ResourceID = sql.NullString{String: "app-1", Valid: true}
err := entry.Save(context.Background())
require.NoError(t, err)
}
// Create entry for a different resource.
entry := models.NewAuditEntry(testDB)
entry.Username = testAdmin
entry.Action = models.AuditActionLogin
entry.ResourceType = models.AuditResourceSession
err := entry.Save(context.Background())
require.NoError(t, err)
// Find by resource.
appEntries, err := models.FindAuditEntriesByResource(
context.Background(), testDB,
models.AuditResourceApp, "app-1", 10,
)
require.NoError(t, err)
assert.Len(t, appEntries, 3)
// All entries.
allEntries, err := models.FindAuditEntries(context.Background(), testDB, 10)
require.NoError(t, err)
assert.Len(t, allEntries, 4)
}
func TestAuditEntryCount(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
count, err := models.CountAuditEntries(context.Background(), testDB)
require.NoError(t, err)
assert.Equal(t, 0, count)
entry := models.NewAuditEntry(testDB)
entry.Username = testAdmin
entry.Action = models.AuditActionLogin
entry.ResourceType = models.AuditResourceSession
err = entry.Save(context.Background())
require.NoError(t, err)
count, err = models.CountAuditEntries(context.Background(), testDB)
require.NoError(t, err)
assert.Equal(t, 1, count)
}
func TestAuditEntryFindLimit(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
for range 5 {
entry := models.NewAuditEntry(testDB)
entry.Username = testAdmin
entry.Action = models.AuditActionLogin
entry.ResourceType = models.AuditResourceSession
err := entry.Save(context.Background())
require.NoError(t, err)
}
entries, err := models.FindAuditEntries(context.Background(), testDB, 3)
require.NoError(t, err)
assert.Len(t, entries, 3)
}
func TestAuditEntryOrderByCreatedAtDesc(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
actions := []models.AuditAction{
models.AuditActionLogin,
models.AuditActionAppCreate,
models.AuditActionLogout,
}
for _, action := range actions {
entry := models.NewAuditEntry(testDB)
entry.Username = testAdmin
entry.Action = action
entry.ResourceType = models.AuditResourceSession
err := entry.Save(context.Background())
require.NoError(t, err)
}
entries, err := models.FindAuditEntries(context.Background(), testDB, 10)
require.NoError(t, err)
require.Len(t, entries, 3)
// Newest first (logout was last inserted).
assert.Equal(t, models.AuditActionLogout, entries[0].Action)
assert.Equal(t, models.AuditActionAppCreate, entries[1].Action)
assert.Equal(t, models.AuditActionLogin, entries[2].Action)
}
// Helper function to create a test app.
func createTestApp(t *testing.T, testDB *database.Database) *models.App {
t.Helper()

View File

@@ -112,12 +112,6 @@ func FindPort(
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.
func FindPortsByAppID(
ctx context.Context,
@@ -128,7 +122,30 @@ func FindPortsByAppID(
SELECT id, app_id, host_port, container_port, protocol
FROM app_ports WHERE app_id = ? ORDER BY host_port`
return findAllByAppID(ctx, db, query, appID, "ports", NewPort)
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying ports by app: %w", err)
}
defer func() { _ = rows.Close() }()
var ports []*Port
for rows.Next() {
port := NewPort(db)
scanErr := rows.Scan(
&port.ID, &port.AppID, &port.HostPort,
&port.ContainerPort, &port.Protocol,
)
if scanErr != nil {
return nil, scanErr
}
ports = append(ports, port)
}
return ports, rows.Err()
}
// DeletePortsByAppID deletes all ports for an app.

View File

@@ -103,12 +103,6 @@ func FindVolume(
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.
func FindVolumesByAppID(
ctx context.Context,
@@ -119,7 +113,30 @@ func FindVolumesByAppID(
SELECT id, app_id, host_path, container_path, readonly
FROM app_volumes WHERE app_id = ? ORDER BY container_path`
return findAllByAppID(ctx, db, query, appID, "volumes", NewVolume)
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying volumes by app: %w", err)
}
defer func() { _ = rows.Close() }()
var volumes []*Volume
for rows.Next() {
vol := NewVolume(db)
scanErr := rows.Scan(
&vol.ID, &vol.AppID, &vol.HostPath,
&vol.ContainerPath, &vol.ReadOnly,
)
if scanErr != nil {
return nil, scanErr
}
volumes = append(volumes, vol)
}
return volumes, rows.Err()
}
// DeleteVolumesByAppID deletes all volumes for an app.

View File

@@ -71,14 +71,8 @@ func (s *Server) SetupRoutes() {
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
r.Get("/apps/{id}/webhooks", s.handlers.HandleAppWebhookEvents())
r.Get(
"/apps/{id}/deployments/{deploymentID}/logs",
s.handlers.HandleDeploymentLogsAPI(),
)
r.Get(
"/apps/{id}/deployments/{deploymentID}/download",
s.handlers.HandleDeploymentLogDownload(),
)
r.Get("/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}/container-logs", s.handlers.HandleContainerLogsAPI())
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
@@ -121,14 +115,13 @@ func (s *Server) SetupRoutes() {
r.Get("/apps", s.handlers.HandleAPIListApps())
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp())
r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments())
r.Get("/audit", s.handlers.HandleAPIAuditLog())
})
})
// Metrics endpoint (optional, with basic auth)
if s.params.Config.MetricsUsername != "" {
s.router.Group(func(r chi.Router) {
r.Use(s.mw.MetricsAuth())
r.Get("/metrics", promhttp.Handler().ServeHTTP)
})
}
// Metrics endpoint (always available, optionally protected with basic auth)
s.router.Group(func(r chi.Router) {
r.Use(s.mw.MetricsAuth())
r.Get("/metrics", promhttp.Handler().ServeHTTP)
})
}

View File

@@ -16,12 +16,6 @@ import (
"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()) {
t.Helper()
@@ -64,8 +58,7 @@ func setupTestService(t *testing.T) (*app.Service, func()) {
}
// 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(
t *testing.T,
appName string,
@@ -80,7 +73,7 @@ func deleteItemTestHelper(
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: appName,
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -99,35 +92,6 @@ func deleteItemTestHelper(
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) {
t.Parallel()
@@ -136,7 +100,7 @@ func TestCreateAppWithGeneratedKeys(t *testing.T) {
input := app.CreateAppInput{
Name: "test-app",
RepoURL: giteaRepoURL,
RepoURL: "git@gitea.example.com:user/repo.git",
Branch: "main",
DockerfilePath: "Dockerfile",
}
@@ -146,7 +110,7 @@ func TestCreateAppWithGeneratedKeys(t *testing.T) {
require.NotNil(t, createdApp)
assert.Equal(t, "test-app", createdApp.Name)
assert.Equal(t, giteaRepoURL, createdApp.RepoURL)
assert.Equal(t, "git@gitea.example.com:user/repo.git", createdApp.RepoURL)
assert.Equal(t, "main", createdApp.Branch)
assert.Equal(t, "Dockerfile", createdApp.DockerfilePath)
assert.NotEmpty(t, createdApp.ID)
@@ -166,7 +130,7 @@ func TestCreateAppDefaults(t *testing.T) {
input := app.CreateAppInput{
Name: "test-app-defaults",
RepoURL: giteaRepoURL,
RepoURL: "git@gitea.example.com:user/repo.git",
}
createdApp, err := svc.CreateApp(context.Background(), input)
@@ -184,7 +148,7 @@ func TestCreateAppOptionalFields(t *testing.T) {
input := app.CreateAppInput{
Name: "test-app-full",
RepoURL: giteaRepoURL,
RepoURL: "git@gitea.example.com:user/repo.git",
Branch: "develop",
DockerNetwork: "my-network",
NtfyTopic: "https://ntfy.sh/my-topic",
@@ -212,7 +176,7 @@ func TestUpdateApp(testingT *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "original-name",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -244,7 +208,7 @@ func TestUpdateApp(testingT *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "test-clear",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
NtfyTopic: "https://ntfy.sh/topic",
SlackWebhook: "https://slack.com/hook",
})
@@ -252,7 +216,7 @@ func TestUpdateApp(testingT *testing.T) {
err = svc.UpdateApp(context.Background(), createdApp, app.UpdateAppInput{
Name: "test-clear",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
Branch: "main",
})
require.NoError(t, err)
@@ -276,7 +240,7 @@ func TestDeleteApp(testingT *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "to-delete",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -300,7 +264,7 @@ func TestGetApp(testingT *testing.T) {
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "findable-app",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -335,7 +299,7 @@ func TestGetAppByWebhookSecret(testingT *testing.T) {
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "webhook-app",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -414,7 +378,7 @@ func TestEnvVarsAddAndRetrieve(t *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "env-test",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -447,33 +411,29 @@ func TestEnvVarsAddAndRetrieve(t *testing.T) {
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) {
t.Parallel()
runDeleteItemTest(t, "env-delete-test", addDeletableEnvVar,
func(ctx context.Context, application *models.App) ([]*models.EnvVar, error) {
return application.GetEnvVars(ctx)
deleteItemTestHelper(t, "env-delete-test",
func(ctx context.Context, svc *app.Service, appID string) error {
return svc.AddEnvVar(ctx, appID, "TO_DELETE", "value")
},
func(ctx context.Context, svc *app.Service, item *models.EnvVar) error {
return svc.DeleteEnvVar(ctx, item.ID)
func(ctx context.Context, application *models.App) (int, error) {
envVars, err := application.GetEnvVars(ctx)
return len(envVars), err
},
func(ctx context.Context, svc *app.Service, application *models.App) error {
envVars, err := application.GetEnvVars(ctx)
if err != nil {
return err
}
return svc.DeleteEnvVar(ctx, envVars[0].ID)
},
)
}
// addDeletableLabel seeds the label removed in the delete test.
func addDeletableLabel(
ctx context.Context, svc *app.Service, appID string,
) error {
return svc.AddLabel(ctx, appID, "to.delete", "value")
}
func TestLabels(testingT *testing.T) {
testingT.Parallel()
@@ -485,7 +445,7 @@ func TestLabels(testingT *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "label-test",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -508,12 +468,22 @@ func TestLabels(testingT *testing.T) {
testingT.Run("deletes label", func(t *testing.T) {
t.Parallel()
runDeleteItemTest(t, "label-delete-test", addDeletableLabel,
func(ctx context.Context, application *models.App) ([]*models.Label, error) {
return application.GetLabels(ctx)
deleteItemTestHelper(t, "label-delete-test",
func(ctx context.Context, svc *app.Service, appID string) error {
return svc.AddLabel(ctx, appID, "to.delete", "value")
},
func(ctx context.Context, svc *app.Service, item *models.Label) error {
return svc.DeleteLabel(ctx, item.ID)
func(ctx context.Context, application *models.App) (int, error) {
labels, err := application.GetLabels(ctx)
return len(labels), err
},
func(ctx context.Context, svc *app.Service, application *models.App) error {
labels, err := application.GetLabels(ctx)
if err != nil {
return err
}
return svc.DeleteLabel(ctx, labels[0].ID)
},
)
})
@@ -527,7 +497,7 @@ func TestVolumesAddAndRetrieve(t *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "volume-test",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -577,7 +547,7 @@ func TestVolumesDelete(t *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "volume-delete-test",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -613,7 +583,7 @@ func TestUpdateAppStatus(testingT *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "status-test",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
assert.Equal(t, models.AppStatusPending, createdApp.Status)

View File

@@ -0,0 +1,126 @@
// Package audit provides audit logging for user actions.
package audit
import (
"context"
"database/sql"
"log/slog"
"net/http"
"go.uber.org/fx"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/metrics"
"sneak.berlin/go/upaas/internal/middleware"
"sneak.berlin/go/upaas/internal/models"
)
// ServiceParams contains dependencies for Service.
type ServiceParams struct {
fx.In
Logger *logger.Logger
Database *database.Database
Metrics *metrics.Metrics
}
// Service provides audit logging functionality.
type Service struct {
log *slog.Logger
db *database.Database
metrics *metrics.Metrics
}
// New creates a new audit Service.
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
return &Service{
log: params.Logger.Get(),
db: params.Database,
metrics: params.Metrics,
}, nil
}
// LogEntry records an audit event.
type LogEntry struct {
UserID int64
Username string
Action models.AuditAction
ResourceType models.AuditResourceType
ResourceID string
Detail string
RemoteIP string
}
// Log records an audit log entry and increments the audit metrics counter.
func (svc *Service) Log(ctx context.Context, entry LogEntry) {
auditEntry := models.NewAuditEntry(svc.db)
auditEntry.Username = entry.Username
auditEntry.Action = entry.Action
auditEntry.ResourceType = entry.ResourceType
if entry.UserID != 0 {
auditEntry.UserID = sql.NullInt64{Int64: entry.UserID, Valid: true}
}
if entry.ResourceID != "" {
auditEntry.ResourceID = sql.NullString{String: entry.ResourceID, Valid: true}
}
if entry.Detail != "" {
auditEntry.Detail = sql.NullString{String: entry.Detail, Valid: true}
}
if entry.RemoteIP != "" {
auditEntry.RemoteIP = sql.NullString{String: entry.RemoteIP, Valid: true}
}
err := auditEntry.Save(ctx)
if err != nil {
svc.log.Error("failed to save audit entry",
"error", err,
"action", entry.Action,
"username", entry.Username,
)
return
}
svc.metrics.AuditEventsTotal.WithLabelValues(string(entry.Action)).Inc()
svc.log.Info("audit",
"action", entry.Action,
"username", entry.Username,
"resource_type", entry.ResourceType,
"resource_id", entry.ResourceID,
)
}
// LogFromRequest records an audit log entry, extracting the remote IP from
// the HTTP request using the middleware's trusted-proxy-aware IP resolution.
func (svc *Service) LogFromRequest(
ctx context.Context,
request *http.Request,
entry LogEntry,
) {
entry.RemoteIP = middleware.RealIP(request)
svc.Log(ctx, entry)
}
// Recent returns the most recent audit log entries.
func (svc *Service) Recent(
ctx context.Context,
limit int,
) ([]*models.AuditEntry, error) {
return models.FindAuditEntries(ctx, svc.db, limit)
}
// ForResource returns audit log entries for a specific resource.
func (svc *Service) ForResource(
ctx context.Context,
resourceType models.AuditResourceType,
resourceID string,
limit int,
) ([]*models.AuditEntry, error) {
return models.FindAuditEntriesByResource(ctx, svc.db, resourceType, resourceID, limit)
}

View File

@@ -0,0 +1,221 @@
package audit_test
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/metrics"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/audit"
)
func setupTestAuditService(t *testing.T) (*audit.Service, *database.Database) {
t.Helper()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
tmpDir := t.TempDir()
cfg := &config.Config{
DataDir: tmpDir,
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
logWrapper := logger.NewForTest(log)
db, err := database.New(fx.Lifecycle(nil), database.Params{
Logger: logWrapper,
Config: cfg,
})
require.NoError(t, err)
reg := prometheus.NewRegistry()
metricsInstance := metrics.NewForTest(reg)
svc, err := audit.New(fx.Lifecycle(nil), audit.ServiceParams{
Logger: logWrapper,
Database: db,
Metrics: metricsInstance,
})
require.NoError(t, err)
return svc, db
}
func TestAuditServiceLog(t *testing.T) {
t.Parallel()
svc, db := setupTestAuditService(t)
ctx := context.Background()
svc.Log(ctx, audit.LogEntry{
UserID: 1,
Username: "admin",
Action: models.AuditActionLogin,
ResourceType: models.AuditResourceSession,
Detail: "user logged in",
RemoteIP: "127.0.0.1",
})
entries, err := models.FindAuditEntries(ctx, db, 10)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "admin", entries[0].Username)
assert.Equal(t, models.AuditActionLogin, entries[0].Action)
assert.Equal(t, "127.0.0.1", entries[0].RemoteIP.String)
}
func TestAuditServiceLogFromRequest(t *testing.T) {
t.Parallel()
svc, db := setupTestAuditService(t)
ctx := context.Background()
request := httptest.NewRequest(http.MethodPost, "/apps", nil)
request.RemoteAddr = "10.0.0.1:12345"
svc.LogFromRequest(ctx, request, audit.LogEntry{
Username: "admin",
Action: models.AuditActionAppCreate,
ResourceType: models.AuditResourceApp,
ResourceID: "app-1",
Detail: "created app",
})
entries, err := models.FindAuditEntries(ctx, db, 10)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "10.0.0.1", entries[0].RemoteIP.String)
assert.Equal(t, "app-1", entries[0].ResourceID.String)
}
func TestAuditServiceLogFromRequestWithXRealIPTrustedProxy(t *testing.T) {
t.Parallel()
svc, db := setupTestAuditService(t)
ctx := context.Background()
// When the request comes from a trusted proxy (RFC1918), X-Real-IP is honoured.
request := httptest.NewRequest(http.MethodPost, "/apps", nil)
request.RemoteAddr = "10.0.0.1:1234"
request.Header.Set("X-Real-IP", "203.0.113.50")
svc.LogFromRequest(ctx, request, audit.LogEntry{
Username: "admin",
Action: models.AuditActionAppCreate,
ResourceType: models.AuditResourceApp,
})
entries, err := models.FindAuditEntries(ctx, db, 10)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "203.0.113.50", entries[0].RemoteIP.String)
}
func TestAuditServiceLogFromRequestWithXRealIPUntrustedProxy(t *testing.T) {
t.Parallel()
svc, db := setupTestAuditService(t)
ctx := context.Background()
// When the request comes from a public IP, X-Real-IP is ignored (anti-spoof).
request := httptest.NewRequest(http.MethodPost, "/apps", nil)
request.RemoteAddr = "203.0.113.99:1234"
request.Header.Set("X-Real-IP", "10.0.0.1")
svc.LogFromRequest(ctx, request, audit.LogEntry{
Username: "admin",
Action: models.AuditActionAppCreate,
ResourceType: models.AuditResourceApp,
})
entries, err := models.FindAuditEntries(ctx, db, 10)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "203.0.113.99", entries[0].RemoteIP.String)
}
func TestAuditServiceRecent(t *testing.T) {
t.Parallel()
svc, _ := setupTestAuditService(t)
ctx := context.Background()
for range 5 {
svc.Log(ctx, audit.LogEntry{
Username: "admin",
Action: models.AuditActionLogin,
ResourceType: models.AuditResourceSession,
})
}
entries, err := svc.Recent(ctx, 3)
require.NoError(t, err)
assert.Len(t, entries, 3)
}
func TestAuditServiceForResource(t *testing.T) {
t.Parallel()
svc, _ := setupTestAuditService(t)
ctx := context.Background()
// Log entries for different resources.
svc.Log(ctx, audit.LogEntry{
Username: "admin",
Action: models.AuditActionAppCreate,
ResourceType: models.AuditResourceApp,
ResourceID: "app-1",
})
svc.Log(ctx, audit.LogEntry{
Username: "admin",
Action: models.AuditActionAppDeploy,
ResourceType: models.AuditResourceApp,
ResourceID: "app-1",
})
svc.Log(ctx, audit.LogEntry{
Username: "admin",
Action: models.AuditActionAppCreate,
ResourceType: models.AuditResourceApp,
ResourceID: "app-2",
})
entries, err := svc.ForResource(ctx, models.AuditResourceApp, "app-1", 10)
require.NoError(t, err)
assert.Len(t, entries, 2)
}
func TestAuditServiceLogWithNoOptionalFields(t *testing.T) {
t.Parallel()
svc, db := setupTestAuditService(t)
ctx := context.Background()
svc.Log(ctx, audit.LogEntry{
Username: "system",
Action: models.AuditActionWebhookReceive,
ResourceType: models.AuditResourceWebhook,
})
entries, err := models.FindAuditEntries(ctx, db, 10)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.False(t, entries[0].UserID.Valid)
assert.False(t, entries[0].ResourceID.Valid)
assert.False(t, entries[0].Detail.Valid)
assert.False(t, entries[0].RemoteIP.Valid)
}

View File

@@ -121,7 +121,7 @@ func getSessionCookie(t *testing.T, svc *auth.Service) *http.Cookie {
require.NoError(t, err)
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
request := httptest.NewRequest(http.MethodGet, "/", nil)
err = svc.CreateSession(recorder, request, user)
require.NoError(t, err)
@@ -144,11 +144,7 @@ func TestSessionCookieSecureFlag(testingT *testing.T) {
svc := setupAuthService(t, false)
cookie := getSessionCookie(t, svc)
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")
})
}
@@ -328,12 +324,7 @@ func TestCreateUserRaceCondition(testingT *testing.T) {
}
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")
})
}
@@ -389,9 +380,7 @@ func TestDestroySessionMaxAge(testingT *testing.T) {
defer cleanup()
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
request := httptest.NewRequest(http.MethodGet, "/", nil)
err := svc.DestroySession(recorder, request)
require.NoError(t, err)

View File

@@ -21,6 +21,7 @@ import (
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/metrics"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/notify"
)
@@ -66,8 +67,7 @@ const logFilePermissions = 0o640
// logTimestampFormat is the format for log file timestamps.
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
// dockerLogMessage represents a Docker build log message.
@@ -88,10 +88,7 @@ type deploymentLogWriter struct {
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{
deployment: deployment,
done: make(chan struct{}),
@@ -212,6 +209,7 @@ type ServiceParams struct {
Database *database.Database
Docker *docker.Client
Notify *notify.Service
Metrics *metrics.Metrics
}
// activeDeploy tracks a running deployment so it can be cancelled.
@@ -226,6 +224,7 @@ type Service struct {
db *database.Database
docker *docker.Client
notify *notify.Service
metrics *metrics.Metrics
config *config.Config
params *ServiceParams
activeDeploys sync.Map // map[string]*activeDeploy - per-app active deployment tracking
@@ -235,12 +234,13 @@ type Service struct {
// New creates a new deploy Service.
func New(lc fx.Lifecycle, params ServiceParams) (*Service, error) {
svc := &Service{
log: params.Logger.Get(),
db: params.Database,
docker: params.Docker,
notify: params.Notify,
config: params.Config,
params: &params,
log: params.Logger.Get(),
db: params.Database,
docker: params.Docker,
notify: params.Notify,
metrics: params.Metrics,
config: params.Config,
params: &params,
}
if lc != nil {
@@ -261,10 +261,7 @@ func (svc *Service) GetBuildDir(appName string) string {
// GetLogFilePath returns the path to the log file for a deployment.
// 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()
if err != nil {
hostname = "unknown"
@@ -282,8 +279,7 @@ func (svc *Service) GetLogFilePath(
// Use started_at timestamp
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
if sha != "" {
filename = fmt.Sprintf("%s_%s_%s.log.txt", app.Name, sha, timestamp)
@@ -316,8 +312,7 @@ func (svc *Service) CancelDeploy(appID string) bool {
// 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.
// 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(
ctx context.Context,
app *models.App,
@@ -336,6 +331,11 @@ func (svc *Service) Deploy(
}
defer svc.unlockApp(app.ID)
// Track in-flight deployments
svc.metrics.DeploymentsInFlight.WithLabelValues(app.Name).Inc()
deployStart := time.Now()
// Set up cancellable context and register as active deploy
deployCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
@@ -343,6 +343,7 @@ func (svc *Service) Deploy(
svc.activeDeploys.Store(app.ID, ad)
defer func() {
svc.metrics.DeploymentsInFlight.WithLabelValues(app.Name).Dec()
cancel()
close(done)
svc.activeDeploys.Delete(app.ID)
@@ -351,8 +352,7 @@ func (svc *Service) Deploy(
// Fetch webhook event and create deployment record
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)
deployment, err := svc.createDeploymentRecord(bgCtx, app, webhookEventID, webhookEvent)
@@ -369,7 +369,7 @@ func (svc *Service) Deploy(
svc.notify.NotifyBuildStart(bgCtx, app, deployment)
return svc.runBuildAndDeploy(deployCtx, bgCtx, app, deployment)
return svc.runBuildAndDeploy(deployCtx, bgCtx, app, deployment, deployStart)
}
// Rollback rolls back an app to its previous image.
@@ -411,10 +411,7 @@ func (svc *Service) createRollbackDeployment(
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
}
@@ -430,11 +427,7 @@ func (svc *Service) executeRollback(
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 {
svc.failDeployment(bgCtx, app, deployment, err)
@@ -443,12 +436,7 @@ func (svc *Service) executeRollback(
containerID, err := svc.docker.CreateContainer(ctx, rollbackOpts)
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)
}
@@ -458,12 +446,7 @@ func (svc *Service) executeRollback(
startErr := svc.docker.StartContainer(ctx, containerID)
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)
}
@@ -494,15 +477,20 @@ func (svc *Service) runBuildAndDeploy(
bgCtx context.Context,
app *models.App,
deployment *models.Deployment,
deployStart time.Time,
) error {
// Build phase with timeout
imageID, err := svc.buildImageWithTimeout(deployCtx, app, deployment)
if err != nil {
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, "")
if cancelErr != nil {
svc.recordDeployMetrics(app.Name, "cancelled", deployStart)
return cancelErr
}
svc.recordDeployMetrics(app.Name, "failed", deployStart)
return err
}
@@ -513,9 +501,13 @@ func (svc *Service) runBuildAndDeploy(
if err != nil {
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, imageID)
if cancelErr != nil {
svc.recordDeployMetrics(app.Name, "cancelled", deployStart)
return cancelErr
}
svc.recordDeployMetrics(app.Name, "failed", deployStart)
return err
}
@@ -531,11 +523,19 @@ func (svc *Service) runBuildAndDeploy(
// Use context.WithoutCancel to ensure health check completes even if
// the parent context is cancelled (e.g., HTTP request ends).
go svc.checkHealthAfterDelay(bgCtx, app, deployment)
go svc.checkHealthAfterDelay(bgCtx, app, deployment, deployStart)
return nil
}
// recordDeployMetrics records deployment completion metrics.
func (svc *Service) recordDeployMetrics(appName, status string, start time.Time) {
duration := time.Since(start).Seconds()
svc.metrics.DeploymentsTotal.WithLabelValues(appName, status).Inc()
svc.metrics.DeploymentDuration.WithLabelValues(appName, status).Observe(duration)
}
// buildImageWithTimeout runs the build phase with a timeout.
func (svc *Service) buildImageWithTimeout(
ctx context.Context,
@@ -722,11 +722,7 @@ func (svc *Service) cleanupCancelledDeploy(
if removeErr != nil {
svc.log.Error("failed to remove image from cancelled deploy",
"error", removeErr, "app", app.Name, "image", imageID)
_ = deployment.AppendLog(
ctx,
"WARNING: failed to clean up image "+
imageID.String()+": "+removeErr.Error(),
)
_ = deployment.AppendLog(ctx, "WARNING: failed to clean up image "+imageID.String()+": "+removeErr.Error())
} else {
svc.log.Info("cleaned up image from cancelled deploy",
"app", app.Name, "image", imageID)
@@ -901,24 +897,14 @@ func (svc *Service) cloneRepository(
err := os.MkdirAll(appBuildsDir, buildsDirPermissions)
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)
}
buildDir, err := os.MkdirTemp(appBuildsDir, fmt.Sprintf("%d-*", deployment.ID))
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)
}
@@ -949,12 +935,7 @@ func (svc *Service) cloneRepository(
)
if cloneErr != nil {
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)
}
@@ -1223,6 +1204,7 @@ func (svc *Service) checkHealthAfterDelay(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
deployStart time.Time,
) {
svc.log.Info(
"waiting 60 seconds to check container health",
@@ -1249,6 +1231,8 @@ func (svc *Service) checkHealthAfterDelay(
svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, err)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
svc.writeLogsToFile(reloadedApp, deployment)
svc.recordDeployMetrics(reloadedApp.Name, "failed", deployStart)
svc.metrics.ContainerHealthy.WithLabelValues(reloadedApp.Name).Set(0)
reloadedApp.Status = models.AppStatusError
_ = reloadedApp.Save(ctx)
@@ -1260,6 +1244,8 @@ func (svc *Service) checkHealthAfterDelay(
svc.notify.NotifyDeploySuccess(ctx, reloadedApp, deployment)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusSuccess)
svc.writeLogsToFile(reloadedApp, deployment)
svc.recordDeployMetrics(reloadedApp.Name, "success", deployStart)
svc.metrics.ContainerHealthy.WithLabelValues(reloadedApp.Name).Set(1)
} else {
svc.log.Warn(
"container unhealthy after 60 seconds",
@@ -1268,6 +1254,8 @@ func (svc *Service) checkHealthAfterDelay(
svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, ErrContainerUnhealthy)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
svc.writeLogsToFile(reloadedApp, deployment)
svc.recordDeployMetrics(reloadedApp.Name, "failed", deployStart)
svc.metrics.ContainerHealthy.WithLabelValues(reloadedApp.Name).Set(0)
reloadedApp.Status = models.AppStatusError
_ = reloadedApp.Save(ctx)
}

View File

@@ -32,10 +32,7 @@ func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) {
require.NoError(t, os.MkdirAll(deployDir, 0o750))
// Create a file inside to verify full removal
require.NoError(
t,
os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o600),
)
require.NoError(t, os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o600))
// Also create a dir for a different deployment (should NOT be removed)
otherDir := filepath.Join(buildDir, "99-xyz789")

View File

@@ -31,9 +31,7 @@ func TestBuildContainerOptionsUsesImageID(t *testing.T) {
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 {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
@@ -79,20 +77,14 @@ func TestBuildContainerOptionsNoResourceLimits(t *testing.T) {
}
}
// buildOptsForApp saves an app configured by setup and returns the container
// options built for it.
func buildOptsForApp(
t *testing.T,
name string,
setup func(app *models.App),
) docker.CreateContainerOptions {
t.Helper()
func TestBuildContainerOptionsCPULimit(t *testing.T) {
t.Parallel()
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = name
setup(app)
app.Name = "cpulimit"
app.CPULimit = sql.NullFloat64{Float64: 0.5, Valid: true}
err := app.Save(context.Background())
if err != nil {
@@ -109,16 +101,6 @@ func buildOptsForApp(
t.Fatalf("buildContainerOptions returned error: %v", err)
}
return opts
}
func TestBuildContainerOptionsCPULimit(t *testing.T) {
t.Parallel()
opts := buildOptsForApp(t, "cpulimit", func(app *models.App) {
app.CPULimit = sql.NullFloat64{Float64: 0.5, Valid: true}
})
if opts.CPULimit != 0.5 {
t.Errorf("expected CPULimit=0.5, got %v", opts.CPULimit)
}
@@ -127,9 +109,26 @@ func TestBuildContainerOptionsCPULimit(t *testing.T) {
func TestBuildContainerOptionsMemoryLimit(t *testing.T) {
t.Parallel()
opts := buildOptsForApp(t, "memlimit", func(app *models.App) {
app.MemoryLimit = sql.NullInt64{Int64: 536870912, Valid: true} // 512m
})
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = "memlimit"
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 {
t.Errorf("expected MemoryLimit=536870912, got %v", opts.MemoryLimit)

View File

@@ -26,11 +26,7 @@ func (svc *Service) CancelActiveDeploy(appID string) {
}
// 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})
}
@@ -45,11 +41,7 @@ func (svc *Service) UnlockApp(appID string) {
}
// 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{
log: log,
config: cfg,

View File

@@ -159,8 +159,7 @@ func (svc *Service) NotifyDeployFailed(
) {
duration := time.Since(deployment.StartedAt)
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")
}
@@ -267,8 +266,7 @@ func (svc *Service) sendNtfy(
request.Header.Set("Title", title)
request.Header.Set("Priority", svc.ntfyPriority(priority))
// #nosec G704 -- URL from validated config, not user input
resp, err := svc.client.Do(request)
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input
if err != nil {
return fmt.Errorf("failed to send ntfy request: %w", err)
}
@@ -365,8 +363,7 @@ func (svc *Service) sendSlack(
request.Header.Set("Content-Type", "application/json")
// #nosec G704 -- URL from validated config, not user input
resp, err := svc.client.Do(request)
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input
if err != nil {
return fmt.Errorf("failed to send slack request: %w", err)
}

View File

@@ -1,237 +0,0 @@
package webhook
import "encoding/json"
// GiteaPushPayload represents a Gitea push webhook payload.
//
//nolint:tagliatelle // Field names match Gitea API (snake_case)
type GiteaPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL UnparsedURL `json:"compare_url"`
Repository struct {
FullName string `json:"full_name"`
CloneURL UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL UnparsedURL `json:"html_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
Email string `json:"email"`
} `json:"pusher"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// GitHubPushPayload represents a GitHub push webhook payload.
//
//nolint:tagliatelle // Field names match GitHub API (snake_case)
type GitHubPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL string `json:"compare"`
Repository struct {
FullName string `json:"full_name"`
CloneURL UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL UnparsedURL `json:"html_url"`
} `json:"repository"`
Pusher struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"pusher"`
HeadCommit *struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
} `json:"head_commit"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// GitLabPushPayload represents a GitLab push webhook payload.
//
//nolint:tagliatelle // Field names match GitLab API (snake_case)
type GitLabPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
Project struct {
PathWithNamespace string `json:"path_with_namespace"`
GitHTTPURL UnparsedURL `json:"git_http_url"`
GitSSHURL string `json:"git_ssh_url"`
WebURL UnparsedURL `json:"web_url"`
} `json:"project"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// ParsePushPayload parses a raw webhook payload into a normalized PushEvent
// based on the detected webhook source. Returns an error if JSON unmarshaling
// fails. For SourceUnknown, falls back to Gitea format for backward
// compatibility.
func ParsePushPayload(source Source, payload []byte) (*PushEvent, error) {
switch source {
case SourceGitHub:
return parsePush(payload, githubPushEvent)
case SourceGitLab:
return parsePush(payload, gitlabPushEvent)
case SourceGitea, SourceUnknown:
// Gitea and unknown both use Gitea format for backward compatibility.
return parsePush(payload, giteaPushEvent)
}
// Unreachable for known source values, but satisfies exhaustive checker.
return parsePush(payload, giteaPushEvent)
}
// parsePush unmarshals payload into P and converts it into a normalized
// PushEvent via build.
func parsePush[P any](payload []byte, build func(P) *PushEvent) (*PushEvent, error) {
var p P
unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil {
return nil, unmarshalErr
}
return build(p), nil
}
// basePushEvent builds a PushEvent populated with the fields shared by all
// webhook sources.
func basePushEvent(source Source, ref, before, after string) *PushEvent {
return &PushEvent{
Source: source,
Ref: ref,
Before: before,
After: after,
Branch: extractBranch(ref),
}
}
// giteaPushEvent converts a Gitea push payload to a normalized PushEvent.
func giteaPushEvent(p GiteaPushPayload) *PushEvent {
event := basePushEvent(SourceGitea, p.Ref, p.Before, p.After)
event.RepoName = p.Repository.FullName
event.CloneURL = p.Repository.CloneURL
event.HTMLURL = p.Repository.HTMLURL
event.CommitURL = extractGiteaCommitURL(p)
event.Pusher = p.Pusher.Username
return event
}
// gitlabPushEvent converts a GitLab push payload to a normalized PushEvent.
func gitlabPushEvent(p GitLabPushPayload) *PushEvent {
event := basePushEvent(SourceGitLab, p.Ref, p.Before, p.After)
event.RepoName = p.Project.PathWithNamespace
event.CloneURL = p.Project.GitHTTPURL
event.HTMLURL = p.Project.WebURL
event.CommitURL = extractGitLabCommitURL(p)
event.Pusher = p.UserName
return event
}
// githubPushEvent converts a GitHub push payload to a normalized PushEvent.
func githubPushEvent(p GitHubPushPayload) *PushEvent {
event := basePushEvent(SourceGitHub, p.Ref, p.Before, p.After)
event.RepoName = p.Repository.FullName
event.CloneURL = p.Repository.CloneURL
event.HTMLURL = p.Repository.HTMLURL
event.CommitURL = extractGitHubCommitURL(p)
event.Pusher = p.Pusher.Name
return event
}
// extractBranch extracts the branch name from a git ref.
func extractBranch(ref string) string {
// refs/heads/main -> main
const prefix = "refs/heads/"
if len(ref) >= len(prefix) && ref[:len(prefix)] == prefix {
return ref[len(prefix):]
}
return ref
}
// extractGiteaCommitURL extracts the commit URL from a Gitea push payload.
// Prefers the URL from the head commit, falls back to constructing from repo URL.
func extractGiteaCommitURL(payload GiteaPushPayload) UnparsedURL {
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
if payload.Repository.HTMLURL != "" && payload.After != "" {
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
}
return ""
}
// extractGitHubCommitURL extracts the commit URL from a GitHub push payload.
// Prefers head_commit.url, then searches commits, then constructs from repo URL.
func extractGitHubCommitURL(payload GitHubPushPayload) UnparsedURL {
if payload.HeadCommit != nil && payload.HeadCommit.URL != "" {
return payload.HeadCommit.URL
}
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
if payload.Repository.HTMLURL != "" && payload.After != "" {
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
}
return ""
}
// extractGitLabCommitURL extracts the commit URL from a GitLab push payload.
// Prefers commit URL from the commits list, falls back to constructing from
// project web URL.
func extractGitLabCommitURL(payload GitLabPushPayload) UnparsedURL {
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
if payload.Project.WebURL != "" && payload.After != "" {
return UnparsedURL(payload.Project.WebURL.String() + "/-/commit/" + payload.After)
}
return ""
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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