Compare commits

..

10 Commits

Author SHA1 Message Date
clawbot
ef109b9513 fix: address review findings for observability PR
All checks were successful
Check / check (pull_request) Successful in 1m42s
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-17 02:52:34 -07:00
clawbot
f558e2cdd8 feat: add observability improvements (metrics, audit log, structured logging)
All checks were successful
Check / check (pull_request) Successful in 1m45s
- 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-17 02:23:44 -07:00
fd110e69db feat: monolithic env var editing with bulk save (#158)
All checks were successful
Check / check (push) Successful in 6s
This PR fixes env var handling by consolidating individual add/edit/delete operations into a single monolithic bulk save.

## Changes

- **Template**: Restored original table-based UI with key/value rows, edit/delete buttons, and add form. Uses Alpine.js to manage the env var list client-side. On form submit, all env vars are collected into a hidden textarea and POSTed as a single bulk request.
- **Handler**: `HandleEnvVarSave` atomically replaces all env vars (DELETE all + INSERT full set).
- **Routes**: Single `POST /apps/{id}/env` endpoint replaces individual env var CRUD routes.
- **Models**: Added `DeleteEnvVarsByAppID` and `FindEnvVarsByAppID` for bulk operations.

closes #156
closes #163

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #158
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-11 12:06:56 +01:00
e1dc865226 feat: add webhook event history UI page (#164)
All checks were successful
Check / check (push) Successful in 4s
## Summary

Adds a per-app webhook event history page at `/apps/{id}/webhooks` showing received webhook events with match/no-match status.

## Changes

- **New template** `webhook_events.html` — displays webhook events in a table with time, event type, branch, commit SHA (linked when URL available), and match status badges
- **New handler** `HandleAppWebhookEvents()` in `webhook_events.go` — fetches app and its webhook events (limit 100)
- **New route** `GET /apps/{id}/webhooks` — registered in protected routes group
- **Template registration** — added `webhook_events.html` to the template cache in `templates.go`
- **Model enhancement** — added `ShortCommit()` method to `WebhookEvent` for truncated SHA display
- **App detail link** — added "Event History" link in the Webhook URL card on the app detail page

## UI

Follows the existing UI patterns (Tailwind CSS classes, Alpine.js `relativeTime`, badge styles, empty state, back-navigation). The page mirrors the deployments history page layout.

closes [#85](#85)

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #164
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 18:53:58 +01:00
49ff625ac4 fix: add missing Makefile targets (docker, hooks) and test timeout (#159)
All checks were successful
Check / check (push) Successful in 4s
## Changes

- Add `docker` target (`docker build .`)
- Add `hooks` target (installs pre-commit hook running `make check`)
- Add 30-second timeout to `test` target (`-timeout 30s`)
- Update `.PHONY` to include new targets
- Update README to document all Makefile targets (`fmt-check`, `docker`, `hooks`)
- Run `make fmt` to fix JS formatting via prettier

`docker build .` passes 

closes #136, closes #137

<!-- session: agent:sdlc-manager:subagent:44375174-444b-43bf-a341-2def7ebb9fdf -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #159
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 01:09:15 +01:00
ab63670043 fix: pass notification settings from create form to service (#160)
All checks were successful
Check / check (push) Successful in 3m49s
## Summary

`HandleAppCreate` was not reading `docker_network`, `ntfy_topic`, or `slack_webhook` form values from the create app form. These fields were silently dropped during app creation, even though:
- `app_new.html` had the form fields
- `CreateAppInput` had the corresponding struct fields
- `CreateApp` already handled them correctly

The edit/update flow was unaffected — the bug was exclusively in the create path.

## Changes

- Read `docker_network`, `ntfy_topic`, `slack_webhook` form values in `HandleAppCreate`
- Pass them to `CreateAppInput`
- Include them in template re-render data (preserves values on validation errors)

closes #157

<!-- session: agent:sdlc-manager:subagent:1fb3582d-1eff-4309-b166-df5046a1b885 -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #160
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 01:01:32 +01:00
1cd433b069 chore: add REPO_POLICIES compliance files (#155)
All checks were successful
Check / check (push) Successful in 6s
Add `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and `.dockerignore` to bring the repository into compliance with REPO_POLICIES standards.

### Changes

- **`.gitignore`** ([#132](#132)): Standard template from `sneak/prompts` plus Go-specific entries (bin/, *.exe, *.test, etc.)
- **`.editorconfig`** ([#133](#133)): root=true, UTF-8, LF line endings, trim trailing whitespace, final newline. Go and Makefile use tabs, everything else 2 spaces.
- **`REPO_POLICIES.md`** ([#134](#134)): Copied as-is from `sneak/prompts` (last_modified: 2026-02-22)
- **`.dockerignore`** ([#135](#135)): Excludes `.git`, `bin/`, `.editorconfig`, `.vscode/`, `.idea/`, `*.test`, `LICENSE`, and documentation files. Keeps all files needed by the Dockerfile (source code, go.mod, go.sum, Makefile, .golangci.yml, static/, templates/).

`docker build .` passes with these changes.

closes #132, closes #133, closes #134, closes #135

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #155
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-03 18:07:44 +01:00
94639a47e9 fix: add COPY --from=lint to builder stage to force lint execution (#154)
All checks were successful
Check / check (push) Successful in 1m30s
BuildKit skips unreferenced stages silently. The lint stage (added in PR [#152](#152)) was never referenced by the builder stage via `COPY --from`, so it was being skipped entirely during `docker build .`. Linting was not actually running in CI.

This adds `COPY --from=lint /src/go.sum /dev/null` to the builder stage, creating a stage dependency that forces the lint stage to complete before the build proceeds.

Verified: `docker build .` now runs the lint stage (fmt-check + lint) and passes.

closes #153

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #154
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-01 23:46:52 +01:00
12446f9f79 fix: change module path to sneak.berlin/go/upaas (closes #143) (#149)
All checks were successful
Check / check (push) Successful in 1m16s
Changes the Go module path from `git.eeqj.de/sneak/upaas` to `sneak.berlin/go/upaas`.

All import paths in Go files updated accordingly. `go mod tidy` and `make check` pass cleanly.

fixes #143

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #149
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-01 23:22:18 +01:00
877fb2c0c5 Split Dockerfile into lint + build stages for faster CI feedback (#152)
All checks were successful
Check / check (push) Successful in 1m4s
## Summary

Splits the Dockerfile into separate lint and build stages to provide faster CI feedback on formatting and lint issues.

### Changes

**Dockerfile:**
- **Lint stage** (`golangci/golangci-lint:v2.10.1`, pinned by sha256): Runs `make fmt-check` and `make lint` using the official golangci-lint image which has the linter pre-installed. No more downloading golangci-lint on every build.
- **Build stage** (`golang:1.25-alpine`, pinned by sha256): Runs `make test` and `make build`. Same alpine image as before.
- **Runtime stage**: Unchanged.

**Makefile:**
- Added `fmt-check` target for standalone gofmt checking.
- Refactored `check` target to use `fmt-check`, `lint`, `test` as dependencies instead of inline commands. Still works identically for local use.

### Benefits
- Lint failures surface immediately without waiting for golangci-lint download
- Uses official pre-built golangci-lint image instead of manual binary download
- Cleaner separation of concerns between lint and build stages
- `make check` still runs everything sequentially for local development

closes #151

Co-authored-by: clawbot <clawbot@eeqj.de>
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #152
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-01 21:19:21 +01:00
71 changed files with 3060 additions and 903 deletions

10
.dockerignore Normal file
View File

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

15
.editorconfig Normal file
View File

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

31
.gitignore vendored Normal file
View File

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

View File

@@ -1,24 +1,6 @@
# Build stage # Lint stage — fast feedback on formatting and lint issues
# golang:1.25-alpine # golangci/golangci-lint:v2.10.1
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder FROM golangci/golangci-lint@sha256:ea84d14c2fef724411be7dc45e09e6ef721d748315252b02df19a7e3113ee763 AS lint
RUN apk add --no-cache git make gcc musl-dev
# Install golangci-lint v2 (pre-built binary — go install fails in alpine due to missing linker)
RUN set -e; \
GOLANGCI_VERSION="2.10.1"; \
case "$(uname -m)" in \
x86_64) ARCH="amd64"; SHA256="dfa775874cf0561b404a02a8f4481fc69b28091da95aa697259820d429b09c99" ;; \
aarch64) ARCH="arm64"; SHA256="6652b42ae02915eb2f9cb2a2e0cac99514c8eded8388d88ae3e06e1a52c00de8" ;; \
*) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; \
esac; \
wget -q -O /tmp/golangci-lint.tar.gz \
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_VERSION}/golangci-lint-${GOLANGCI_VERSION}-linux-${ARCH}.tar.gz"; \
echo "${SHA256} /tmp/golangci-lint.tar.gz" | sha256sum -c -; \
tar -xzf /tmp/golangci-lint.tar.gz -C /usr/local/bin --strip-components=1 "golangci-lint-${GOLANGCI_VERSION}-linux-${ARCH}/golangci-lint"; \
rm /tmp/golangci-lint.tar.gz; \
golangci-lint version
RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 # v0.42.0
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -26,10 +8,25 @@ RUN go mod download
COPY . . COPY . .
# Run all checks - build fails if any check fails RUN make fmt-check
RUN make check RUN make lint
# Build the binary # Build stage — tests and compilation
# golang:1.25-alpine
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
# Force BuildKit to run the lint stage by creating a stage dependency
COPY --from=lint /src/go.sum /dev/null
RUN apk add --no-cache git make gcc musl-dev
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
RUN make build RUN make build
# Runtime stage # Runtime stage

View File

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

View File

@@ -36,11 +36,13 @@ upaas/
│ ├── handlers/ # HTTP request handlers │ ├── handlers/ # HTTP request handlers
│ ├── healthcheck/ # Health status service │ ├── healthcheck/ # Health status service
│ ├── logger/ # Structured logging (slog) │ ├── 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 │ ├── models/ # Active Record style database models
│ ├── server/ # HTTP server and routes │ ├── server/ # HTTP server and routes
│ ├── service/ │ ├── service/
│ │ ├── app/ # App management service │ │ ├── app/ # App management service
│ │ ├── audit/ # Audit logging service
│ │ ├── auth/ # Authentication service │ │ ├── auth/ # Authentication service
│ │ ├── deploy/ # Deployment orchestration │ │ ├── deploy/ # Deployment orchestration
│ │ ├── notify/ # Notifications (ntfy, Slack) │ │ ├── notify/ # Notifications (ntfy, Slack)
@@ -58,16 +60,18 @@ Uses Uber fx for dependency injection. Components are wired in this order:
2. `logger` - Structured logging 2. `logger` - Structured logging
3. `config` - Configuration loading 3. `config` - Configuration loading
4. `database` - SQLite connection + migrations 4. `database` - SQLite connection + migrations
5. `healthcheck` - Health status 5. `metrics` - Prometheus metrics registration
6. `auth` - Authentication service 6. `healthcheck` - Health status
7. `app` - App management 7. `auth` - Authentication service
8. `docker` - Docker client 8. `app` - App management
9. `notify` - Notification service 9. `docker` - Docker client
10. `deploy` - Deployment service 10. `notify` - Notification service
11. `webhook` - Webhook processing 11. `audit` - Audit logging service
12. `middleware` - HTTP middleware 12. `deploy` - Deployment service
13. `handlers` - HTTP handlers 13. `webhook` - Webhook processing
14. `server` - HTTP server 14. `middleware` - HTTP middleware
15. `handlers` - HTTP handlers
16. `server` - HTTP server
### Request Flow ### Request Flow
@@ -111,10 +115,13 @@ chi Router ──► Middleware Stack ──► Handler
```bash ```bash
make fmt # Format code make fmt # Format code
make fmt-check # Check formatting (read-only, fails if unformatted)
make lint # Run comprehensive linting make lint # Run comprehensive linting
make test # Run tests with race detection make test # Run tests with race detection (30s timeout)
make check # Verify everything passes (lint, test, build, format) make check # Verify everything passes (fmt-check, lint, test)
make build # Build binary make build # Build binary
make docker # Build Docker image
make hooks # Install pre-commit hook (runs make check)
``` ```
### Commit Requirements ### Commit Requirements
@@ -208,6 +215,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`. 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 ## License
WTFPL WTFPL

188
REPO_POLICIES.md Normal file
View File

@@ -0,0 +1,188 @@
---
title: Repository Policies
last_modified: 2026-02-22
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make 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
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
a successful build implies all checks pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: `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
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- `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.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations tracking
table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.). There
is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

View File

@@ -4,20 +4,22 @@ package main
import ( import (
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker" "sneak.berlin/go/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/handlers" "sneak.berlin/go/upaas/internal/handlers"
"git.eeqj.de/sneak/upaas/internal/healthcheck" "sneak.berlin/go/upaas/internal/healthcheck"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/middleware" "sneak.berlin/go/upaas/internal/metrics"
"git.eeqj.de/sneak/upaas/internal/server" "sneak.berlin/go/upaas/internal/middleware"
"git.eeqj.de/sneak/upaas/internal/service/app" "sneak.berlin/go/upaas/internal/server"
"git.eeqj.de/sneak/upaas/internal/service/auth" "sneak.berlin/go/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/service/audit"
"git.eeqj.de/sneak/upaas/internal/service/notify" "sneak.berlin/go/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/internal/service/webhook" "sneak.berlin/go/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/notify"
"sneak.berlin/go/upaas/internal/service/webhook"
_ "github.com/joho/godotenv/autoload" _ "github.com/joho/godotenv/autoload"
) )
@@ -41,6 +43,7 @@ func main() {
logger.New, logger.New,
config.New, config.New,
database.New, database.New,
metrics.New,
healthcheck.New, healthcheck.New,
auth.New, auth.New,
app.New, app.New,
@@ -48,6 +51,7 @@ func main() {
notify.New, notify.New,
deploy.New, deploy.New,
webhook.New, webhook.New,
audit.New,
middleware.New, middleware.New,
handlers.New, handlers.New,
server.New, server.New,

2
go.mod
View File

@@ -1,4 +1,4 @@
module git.eeqj.de/sneak/upaas module sneak.berlin/go/upaas
go 1.25 go 1.25

View File

@@ -13,8 +13,8 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
) )
// defaultPort is the default HTTP server port. // defaultPort is the default HTTP server port.

View File

@@ -14,8 +14,8 @@ import (
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
) )
// dataDirPermissions is the file permission for the data directory. // dataDirPermissions is the file permission for the data directory.

View File

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

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

@@ -5,8 +5,8 @@ import (
"os" "os"
"testing" "testing"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
) )
// NewTestDatabase creates an in-memory Database for testing. // NewTestDatabase creates an in-memory Database for testing.

View File

@@ -25,9 +25,9 @@ import (
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
) )
// sshKeyPermissions is the file permission for SSH private keys. // sshKeyPermissions is the file permission for SSH private keys.

View File

@@ -1,13 +1,14 @@
package handlers package handlers
import ( import (
"database/sql"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
) )
// apiAppResponse is the JSON representation of an app. // apiAppResponse is the JSON representation of an app.
@@ -120,6 +121,9 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
return return
} }
h.auditLog(request, models.AuditActionLogin,
models.AuditResourceSession, "", "api login")
h.respondJSON(writer, request, loginResponse{ h.respondJSON(writer, request, loginResponse{
UserID: user.ID, UserID: user.ID,
Username: user.Username, Username: user.Username,
@@ -243,3 +247,79 @@ func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
}, http.StatusOK) }, 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

@@ -11,7 +11,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"git.eeqj.de/sneak/upaas/internal/service/app" "sneak.berlin/go/upaas/internal/service/app"
) )
// apiRouter builds a chi router with the API routes using session auth middleware. // apiRouter builds a chi router with the API routes using session auth middleware.

View File

@@ -15,9 +15,9 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/app" "sneak.berlin/go/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/templates" "sneak.berlin/go/upaas/templates"
) )
const ( const (
@@ -54,12 +54,18 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
repoURL := request.FormValue("repo_url") repoURL := request.FormValue("repo_url")
branch := request.FormValue("branch") branch := request.FormValue("branch")
dockerfilePath := request.FormValue("dockerfile_path") dockerfilePath := request.FormValue("dockerfile_path")
dockerNetwork := request.FormValue("docker_network")
ntfyTopic := request.FormValue("ntfy_topic")
slackWebhook := request.FormValue("slack_webhook")
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"Name": name, "Name": name,
"RepoURL": repoURL, "RepoURL": repoURL,
"Branch": branch, "Branch": branch,
"DockerfilePath": dockerfilePath, "DockerfilePath": dockerfilePath,
"DockerNetwork": dockerNetwork,
"NtfyTopic": ntfyTopic,
"SlackWebhook": slackWebhook,
}, request) }, request)
if name == "" || repoURL == "" { if name == "" || repoURL == "" {
@@ -100,6 +106,9 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
RepoURL: repoURL, RepoURL: repoURL,
Branch: branch, Branch: branch,
DockerfilePath: dockerfilePath, DockerfilePath: dockerfilePath,
DockerNetwork: dockerNetwork,
NtfyTopic: ntfyTopic,
SlackWebhook: slackWebhook,
}, },
) )
if createErr != nil { if createErr != nil {
@@ -110,6 +119,9 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
return return
} }
h.auditLog(request, models.AuditActionAppCreate,
models.AuditResourceApp, createdApp.ID, "created app: "+createdApp.Name)
http.Redirect(writer, request, "/apps/"+createdApp.ID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+createdApp.ID, http.StatusSeeOther)
} }
} }
@@ -280,6 +292,9 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
return return
} }
h.auditLog(request, models.AuditActionAppUpdate,
models.AuditResourceApp, application.ID, "updated app: "+application.Name)
redirectURL := "/apps/" + application.ID + "?success=updated" redirectURL := "/apps/" + application.ID + "?success=updated"
http.Redirect(writer, request, redirectURL, http.StatusSeeOther) http.Redirect(writer, request, redirectURL, http.StatusSeeOther)
} }
@@ -335,6 +350,9 @@ func (h *Handlers) HandleAppDelete() http.HandlerFunc {
return return
} }
h.auditLog(request, models.AuditActionAppDelete,
models.AuditResourceApp, appID, "deleted app: "+application.Name)
http.Redirect(writer, request, "/", http.StatusSeeOther) http.Redirect(writer, request, "/", http.StatusSeeOther)
} }
} }
@@ -351,6 +369,9 @@ func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
return return
} }
h.auditLog(request, models.AuditActionAppDeploy,
models.AuditResourceApp, application.ID, "manual deploy: "+application.Name)
// Trigger deployment in background with a detached context // Trigger deployment in background with a detached context
// so the deployment continues even if the HTTP request is cancelled // so the deployment continues even if the HTTP request is cancelled
deployCtx := context.WithoutCancel(request.Context()) deployCtx := context.WithoutCancel(request.Context())
@@ -390,6 +411,8 @@ func (h *Handlers) HandleCancelDeploy() http.HandlerFunc {
cancelled := h.deploy.CancelDeploy(application.ID) cancelled := h.deploy.CancelDeploy(application.ID)
if cancelled { if cancelled {
h.log.Info("deployment cancelled by user", "app", application.Name) h.log.Info("deployment cancelled by user", "app", application.Name)
h.auditLog(request, models.AuditActionDeployCancel,
models.AuditResourceDeployment, application.ID, "cancelled deploy: "+application.Name)
} }
http.Redirect( http.Redirect(
@@ -421,6 +444,9 @@ func (h *Handlers) HandleAppRollback() http.HandlerFunc {
return return
} }
h.auditLog(request, models.AuditActionAppRollback,
models.AuditResourceApp, application.ID, "rolled back: "+application.Name)
http.Redirect(writer, request, "/apps/"+application.ID+"?success=rolledback", http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+application.ID+"?success=rolledback", http.StatusSeeOther)
} }
} }
@@ -825,11 +851,29 @@ func (h *Handlers) handleContainerAction(
} else { } else {
h.log.Info("container action completed", h.log.Info("container action completed",
"action", action, "app", application.Name, "container", containerID) "action", action, "app", application.Name, "container", containerID)
auditAction := containerActionToAuditAction(action)
h.auditLog(request, auditAction,
models.AuditResourceApp, appID, string(action)+" container: "+application.Name)
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) 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. // HandleAppRestart handles restarting an app's container.
func (h *Handlers) HandleAppRestart() http.HandlerFunc { func (h *Handlers) HandleAppRestart() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@@ -894,50 +938,96 @@ func (h *Handlers) addKeyValueToApp(
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
} }
// HandleEnvVarAdd handles adding an environment variable. // envPairJSON represents a key-value pair in the JSON request body.
func (h *Handlers) HandleEnvVarAdd() http.HandlerFunc { type envPairJSON struct {
return func(writer http.ResponseWriter, request *http.Request) { Key string `json:"key"`
h.addKeyValueToApp( Value string `json:"value"`
writer,
request,
func(ctx context.Context, application *models.App, key, value string) error {
envVar := models.NewEnvVar(h.db)
envVar.AppID = application.ID
envVar.Key = key
envVar.Value = value
return envVar.Save(ctx)
},
)
}
} }
// HandleEnvVarDelete handles deleting an environment variable. // envVarMaxBodyBytes is the maximum allowed request body size for env var saves (1 MB).
func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc { const envVarMaxBodyBytes = 1 << 20
// validateEnvPairs validates env var pairs.
// It rejects empty keys and duplicate keys (returns a non-empty error string).
func validateEnvPairs(pairs []envPairJSON) ([]models.EnvVarPair, string) {
seen := make(map[string]bool, len(pairs))
result := make([]models.EnvVarPair, 0, len(pairs))
for _, p := range pairs {
trimmedKey := strings.TrimSpace(p.Key)
if trimmedKey == "" {
return nil, "empty environment variable key is not allowed"
}
if seen[trimmedKey] {
return nil, "duplicate environment variable key: " + trimmedKey
}
seen[trimmedKey] = true
result = append(result, models.EnvVarPair{Key: trimmedKey, Value: p.Value})
}
return result, ""
}
// HandleEnvVarSave handles bulk saving of all environment variables.
// It reads a JSON array of {key, value} objects from the request body,
// deletes all existing env vars for the app, and inserts the full
// submitted set atomically within a database transaction.
// Duplicate keys are rejected with a 400 Bad Request error.
func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id") appID := chi.URLParam(request, "id")
envVarIDStr := chi.URLParam(request, "varID")
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64) application, findErr := models.FindApp(request.Context(), h.db, appID)
if parseErr != nil { if findErr != nil || application == nil {
http.NotFound(writer, request) http.NotFound(writer, request)
return return
} }
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID) // Limit request body size to prevent abuse
if findErr != nil || envVar == nil || envVar.AppID != appID { request.Body = http.MaxBytesReader(writer, request.Body, envVarMaxBodyBytes)
http.NotFound(writer, request)
var pairs []envPairJSON
decodeErr := json.NewDecoder(request.Body).Decode(&pairs)
if decodeErr != nil {
h.respondJSON(writer, request, map[string]string{
"error": "invalid request body",
}, http.StatusBadRequest)
return return
} }
deleteErr := envVar.Delete(request.Context()) modelPairs, validationErr := validateEnvPairs(pairs)
if deleteErr != nil { if validationErr != "" {
h.log.Error("failed to delete env var", "error", deleteErr) h.respondJSON(writer, request, map[string]string{
"error": validationErr,
}, http.StatusBadRequest)
return
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) replaceErr := models.ReplaceEnvVarsByAppID(
request.Context(), h.db, application.ID, modelPairs,
)
if replaceErr != nil {
h.log.Error("failed to replace env vars", "error", replaceErr)
h.respondJSON(writer, request, map[string]string{
"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)
} }
} }
@@ -953,7 +1043,13 @@ func (h *Handlers) HandleLabelAdd() http.HandlerFunc {
label.Key = key label.Key = key
label.Value = value 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
}, },
) )
} }
@@ -982,6 +1078,9 @@ func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
deleteErr := label.Delete(request.Context()) deleteErr := label.Delete(request.Context())
if deleteErr != nil { if deleteErr != nil {
h.log.Error("failed to delete label", "error", deleteErr) h.log.Error("failed to delete label", "error", deleteErr)
} else {
h.auditLog(request, models.AuditActionLabelDelete,
models.AuditResourceLabel, appID, "deleted label: "+label.Key)
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
@@ -1039,6 +1138,10 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
saveErr := volume.Save(request.Context()) saveErr := volume.Save(request.Context())
if saveErr != nil { if saveErr != nil {
h.log.Error("failed to add volume", "error", saveErr) h.log.Error("failed to add volume", "error", saveErr)
} else {
h.auditLog(request, models.AuditActionVolumeAdd,
models.AuditResourceVolume, application.ID,
"added volume: "+hostPath+":"+containerPath)
} }
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
@@ -1068,6 +1171,10 @@ func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
deleteErr := volume.Delete(request.Context()) deleteErr := volume.Delete(request.Context())
if deleteErr != nil { if deleteErr != nil {
h.log.Error("failed to delete volume", "error", deleteErr) 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) http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
@@ -1117,6 +1224,10 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
saveErr := port.Save(request.Context()) saveErr := port.Save(request.Context())
if saveErr != nil { if saveErr != nil {
h.log.Error("failed to save port", "error", saveErr) 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))
} }
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
@@ -1163,6 +1274,10 @@ func (h *Handlers) HandlePortDelete() http.HandlerFunc {
deleteErr := port.Delete(request.Context()) deleteErr := port.Delete(request.Context())
if deleteErr != nil { if deleteErr != nil {
h.log.Error("failed to delete port", "error", deleteErr) 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) http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
@@ -1196,59 +1311,6 @@ func ValidateVolumePath(p string) error {
return nil return nil
} }
// HandleEnvVarEdit handles editing an existing environment variable.
func (h *Handlers) HandleEnvVarEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
envVarIDStr := chi.URLParam(request, "varID")
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
if findErr != nil || envVar == nil || envVar.AppID != appID {
http.NotFound(writer, request)
return
}
formErr := request.ParseForm()
if formErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
key := request.FormValue("key")
value := request.FormValue("value")
if key == "" || value == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
envVar.Key = key
envVar.Value = value
saveErr := envVar.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update env var", "error", saveErr)
}
http.Redirect(
writer,
request,
"/apps/"+appID+"?success=env-updated",
http.StatusSeeOther,
)
}
}
// HandleLabelEdit handles editing an existing label. // HandleLabelEdit handles editing an existing label.
func (h *Handlers) HandleLabelEdit() http.HandlerFunc { func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@@ -1291,6 +1353,9 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
saveErr := label.Save(request.Context()) saveErr := label.Save(request.Context())
if saveErr != nil { if saveErr != nil {
h.log.Error("failed to update label", "error", saveErr) h.log.Error("failed to update label", "error", saveErr)
} else {
h.auditLog(request, models.AuditActionLabelEdit,
models.AuditResourceLabel, appID, "edited label: "+key)
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
@@ -1349,6 +1414,10 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
saveErr := volume.Save(request.Context()) saveErr := volume.Save(request.Context())
if saveErr != nil { if saveErr != nil {
h.log.Error("failed to update volume", "error", saveErr) h.log.Error("failed to update volume", "error", saveErr)
} else {
h.auditLog(request, models.AuditActionVolumeEdit,
models.AuditResourceVolume, appID,
"edited volume: "+hostPath+":"+containerPath)
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)

View File

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

View File

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

View File

@@ -10,16 +10,18 @@ import (
"github.com/gorilla/csrf" "github.com/gorilla/csrf"
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker" "sneak.berlin/go/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/healthcheck" "sneak.berlin/go/upaas/internal/healthcheck"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/service/app" "sneak.berlin/go/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/auth" "sneak.berlin/go/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/service/audit"
"git.eeqj.de/sneak/upaas/internal/service/webhook" "sneak.berlin/go/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/templates" "sneak.berlin/go/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/webhook"
"sneak.berlin/go/upaas/templates"
) )
// Params contains dependencies for Handlers. // Params contains dependencies for Handlers.
@@ -35,6 +37,7 @@ type Params struct {
Deploy *deploy.Service Deploy *deploy.Service
Webhook *webhook.Service Webhook *webhook.Service
Docker *docker.Client Docker *docker.Client
Audit *audit.Service
} }
// Handlers provides HTTP request handlers. // Handlers provides HTTP request handlers.
@@ -48,6 +51,7 @@ type Handlers struct {
deploy *deploy.Service deploy *deploy.Service
webhook *webhook.Service webhook *webhook.Service
docker *docker.Client docker *docker.Client
audit *audit.Service
globals *globals.Globals globals *globals.Globals
} }
@@ -63,10 +67,48 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
deploy: params.Deploy, deploy: params.Deploy,
webhook: params.Webhook, webhook: params.Webhook,
docker: params.Docker, docker: params.Docker,
audit: params.Audit,
globals: params.Globals, globals: params.Globals,
}, nil }, 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. // addGlobals adds version info and CSRF token to template data map.
func (h *Handlers) addGlobals( func (h *Handlers) addGlobals(
data map[string]any, data map[string]any,

View File

@@ -11,25 +11,28 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker" "sneak.berlin/go/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/handlers" "sneak.berlin/go/upaas/internal/handlers"
"git.eeqj.de/sneak/upaas/internal/healthcheck" "sneak.berlin/go/upaas/internal/healthcheck"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/middleware" "sneak.berlin/go/upaas/internal/metrics"
"git.eeqj.de/sneak/upaas/internal/service/app" "sneak.berlin/go/upaas/internal/middleware"
"git.eeqj.de/sneak/upaas/internal/service/auth" "sneak.berlin/go/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/service/audit"
"git.eeqj.de/sneak/upaas/internal/service/notify" "sneak.berlin/go/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/internal/service/webhook" "sneak.berlin/go/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/notify"
"sneak.berlin/go/upaas/internal/service/webhook"
) )
type testContext struct { type testContext struct {
@@ -92,7 +95,8 @@ func createAppServices(
logInstance *logger.Logger, logInstance *logger.Logger,
dbInstance *database.Database, dbInstance *database.Database,
cfg *config.Config, 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() t.Helper()
authSvc, authErr := auth.New(fx.Lifecycle(nil), auth.ServiceParams{ authSvc, authErr := auth.New(fx.Lifecycle(nil), auth.ServiceParams{
@@ -125,6 +129,7 @@ func createAppServices(
Database: dbInstance, Database: dbInstance,
Docker: dockerClient, Docker: dockerClient,
Notify: notifySvc, Notify: notifySvc,
Metrics: metricsInstance,
}) })
require.NoError(t, deployErr) require.NoError(t, deployErr)
@@ -132,10 +137,18 @@ func createAppServices(
Logger: logInstance, Logger: logInstance,
Database: dbInstance, Database: dbInstance,
Deploy: deploySvc, Deploy: deploySvc,
Metrics: metricsInstance,
}) })
require.NoError(t, webhookErr) 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 { func setupTestHandlers(t *testing.T) *testContext {
@@ -145,11 +158,14 @@ func setupTestHandlers(t *testing.T) *testContext {
globalInstance, logInstance, dbInstance, hcInstance := createCoreServices(t, cfg) 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, t,
logInstance, logInstance,
dbInstance, dbInstance,
cfg, cfg,
metricsInstance,
) )
handlersInstance, handlerErr := handlers.New( handlersInstance, handlerErr := handlers.New(
@@ -164,6 +180,7 @@ func setupTestHandlers(t *testing.T) *testContext {
Deploy: deploySvc, Deploy: deploySvc,
Webhook: webhookSvc, Webhook: webhookSvc,
Docker: dockerClient, Docker: dockerClient,
Audit: auditSvc,
}, },
) )
require.NoError(t, handlerErr) require.NoError(t, handlerErr)
@@ -173,6 +190,7 @@ func setupTestHandlers(t *testing.T) *testContext {
Globals: globalInstance, Globals: globalInstance,
Config: cfg, Config: cfg,
Auth: authSvc, Auth: authSvc,
Metrics: metricsInstance,
}) })
require.NoError(t, mwErr) require.NoError(t, mwErr)
@@ -560,45 +578,242 @@ func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) {
cfg.verifyFn(t, testCtx, resourceID) cfg.verifyFn(t, testCtx, resourceID)
} }
// TestDeleteEnvVarOwnershipVerification tests that deleting an env var // TestHandleEnvVarSaveBulk tests that HandleEnvVarSave replaces all env vars
// via another app's URL path returns 404 (IDOR prevention). // for an app with the submitted set (monolithic delete-all + insert-all).
func TestDeleteEnvVarOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern func TestHandleEnvVarSaveBulk(t *testing.T) {
t.Parallel() t.Parallel()
testOwnershipVerification(t, ownedResourceTestConfig{ testCtx := setupTestHandlers(t)
appPrefix1: "envvar-owner-app", createdApp := createTestApp(t, testCtx, "envvar-bulk-app")
appPrefix2: "envvar-other-app",
createFn: func(t *testing.T, tc *testContext, ownerApp *models.App) int64 {
t.Helper()
envVar := models.NewEnvVar(tc.database) // Create some pre-existing env vars
envVar.AppID = ownerApp.ID for _, kv := range [][2]string{{"OLD_KEY", "old_value"}, {"REMOVE_ME", "gone"}} {
envVar.Key = "SECRET" ev := models.NewEnvVar(testCtx.database)
envVar.Value = "hunter2" ev.AppID = createdApp.ID
require.NoError(t, envVar.Save(context.Background())) ev.Key = kv[0]
ev.Value = kv[1]
require.NoError(t, ev.Save(context.Background()))
}
return envVar.ID // Submit a new set as a JSON array of key/value objects
}, body := `[{"key":"NEW_KEY","value":"new_value"},{"key":"ANOTHER","value":"42"}]`
deletePath: func(appID string, resourceID int64) string {
return "/apps/" + appID + "/env/" + strconv.FormatInt(resourceID, 10) + "/delete"
},
chiParams: func(appID string, resourceID int64) map[string]string {
return map[string]string{"id": appID, "varID": strconv.FormatInt(resourceID, 10)}
},
handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleEnvVarDelete() },
verifyFn: func(t *testing.T, tc *testContext, resourceID int64) {
t.Helper()
found, findErr := models.FindEnvVar(context.Background(), tc.database, resourceID) r := chi.NewRouter()
require.NoError(t, findErr) r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
assert.NotNil(t, found, "env var should still exist after IDOR attempt")
}, request := httptest.NewRequest(
}) http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
// Verify old env vars are gone and new ones exist
envVars, err := models.FindEnvVarsByAppID(
context.Background(), testCtx.database, createdApp.ID,
)
require.NoError(t, err)
assert.Len(t, envVars, 2)
keys := make(map[string]string)
for _, ev := range envVars {
keys[ev.Key] = ev.Value
}
assert.Equal(t, "new_value", keys["NEW_KEY"])
assert.Equal(t, "42", keys["ANOTHER"])
assert.Empty(t, keys["OLD_KEY"], "old env vars should be deleted")
assert.Empty(t, keys["REMOVE_ME"], "old env vars should be deleted")
}
// TestHandleEnvVarSaveAppNotFound tests that HandleEnvVarSave returns 404
// for a non-existent app.
func TestHandleEnvVarSaveAppNotFound(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
body := `[{"key":"KEY","value":"value"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/nonexistent-id/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusNotFound, recorder.Code)
}
// TestHandleEnvVarSaveEmptyKeyRejected verifies that submitting a JSON
// array containing an entry with an empty key returns 400.
func TestHandleEnvVarSaveEmptyKeyRejected(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-emptykey-app")
body := `[{"key":"VALID_KEY","value":"ok"},{"key":"","value":"bad"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
}
// TestHandleEnvVarSaveDuplicateKeyRejected verifies that when the client
// sends duplicate keys, the server rejects them with 400 Bad Request.
func TestHandleEnvVarSaveDuplicateKeyRejected(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-dedup-app")
// Send two entries with the same key — should be rejected
body := `[{"key":"FOO","value":"first"},{"key":"BAR","value":"bar"},{"key":"FOO","value":"second"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.Contains(t, recorder.Body.String(), "duplicate environment variable key: FOO")
}
// TestHandleEnvVarSaveCrossAppIsolation verifies that posting env vars
// to appA's endpoint does not affect appB's env vars (IDOR prevention).
func TestHandleEnvVarSaveCrossAppIsolation(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
appA := createTestApp(t, testCtx, "envvar-iso-appA")
appB := createTestApp(t, testCtx, "envvar-iso-appB")
// Give appB some env vars
for _, kv := range [][2]string{{"B_KEY1", "b_val1"}, {"B_KEY2", "b_val2"}} {
ev := models.NewEnvVar(testCtx.database)
ev.AppID = appB.ID
ev.Key = kv[0]
ev.Value = kv[1]
require.NoError(t, ev.Save(context.Background()))
}
// POST new env vars to appA's endpoint
body := `[{"key":"A_KEY","value":"a_val"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+appA.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
// Verify appA has exactly what we sent
appAVars, err := models.FindEnvVarsByAppID(
context.Background(), testCtx.database, appA.ID,
)
require.NoError(t, err)
assert.Len(t, appAVars, 1)
assert.Equal(t, "A_KEY", appAVars[0].Key)
// Verify appB's env vars are completely untouched
appBVars, err := models.FindEnvVarsByAppID(
context.Background(), testCtx.database, appB.ID,
)
require.NoError(t, err)
assert.Len(t, appBVars, 2, "appB env vars must not be affected")
bKeys := make(map[string]string)
for _, ev := range appBVars {
bKeys[ev.Key] = ev.Value
}
assert.Equal(t, "b_val1", bKeys["B_KEY1"])
assert.Equal(t, "b_val2", bKeys["B_KEY2"])
}
// TestHandleEnvVarSaveBodySizeLimit verifies that a request body
// exceeding the 1 MB limit is rejected.
func TestHandleEnvVarSaveBodySizeLimit(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-sizelimit-app")
// Build a JSON body that exceeds 1 MB
// Each entry is ~30 bytes; 40000 entries ≈ 1.2 MB
var sb strings.Builder
sb.WriteString("[")
for i := range 40000 {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(`{"key":"K` + strconv.Itoa(i) + `","value":"val"}`)
}
sb.WriteString("]")
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(sb.String()),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusBadRequest, recorder.Code,
"oversized body should be rejected with 400")
} }
// TestDeleteLabelOwnershipVerification tests that deleting a label // TestDeleteLabelOwnershipVerification tests that deleting a label
// via another app's URL path returns 404 (IDOR prevention). // via another app's URL path returns 404 (IDOR prevention).
func TestDeleteLabelOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern func TestDeleteLabelOwnershipVerification(t *testing.T) {
t.Parallel() t.Parallel()
testOwnershipVerification(t, ownedResourceTestConfig{ testOwnershipVerification(t, ownedResourceTestConfig{
@@ -714,41 +929,43 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
assert.NotNil(t, found, "port should still exist after IDOR attempt") assert.NotNil(t, found, "port should still exist after IDOR attempt")
} }
// TestHandleEnvVarDeleteUsesCorrectRouteParam verifies that HandleEnvVarDelete // TestHandleEnvVarSaveEmptyClears verifies that submitting an empty JSON
// reads the "varID" chi URL parameter (matching the route definition {varID}), // array deletes all existing env vars for the app.
// not a mismatched name like "envID". func TestHandleEnvVarSaveEmptyClears(t *testing.T) {
func TestHandleEnvVarDeleteUsesCorrectRouteParam(t *testing.T) {
t.Parallel() t.Parallel()
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-clear-app")
createdApp := createTestApp(t, testCtx, "envdelete-param-app") // Create a pre-existing env var
ev := models.NewEnvVar(testCtx.database)
ev.AppID = createdApp.ID
ev.Key = "DELETE_ME"
ev.Value = "gone"
require.NoError(t, ev.Save(context.Background()))
envVar := models.NewEnvVar(testCtx.database) // Submit empty JSON array
envVar.AppID = createdApp.ID
envVar.Key = "DELETE_ME"
envVar.Value = "gone"
require.NoError(t, envVar.Save(context.Background()))
// Use chi router with the real route pattern to test param name
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/apps/{id}/env-vars/{varID}/delete", testCtx.handlers.HandleEnvVarDelete()) r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest( request := httptest.NewRequest(
http.MethodPost, http.MethodPost,
"/apps/"+createdApp.ID+"/env-vars/"+strconv.FormatInt(envVar.ID, 10)+"/delete", "/apps/"+createdApp.ID+"/env",
nil, strings.NewReader("[]"),
) )
recorder := httptest.NewRecorder() request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request) r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code) assert.Equal(t, http.StatusOK, recorder.Code)
// Verify the env var was actually deleted // Verify all env vars are gone
found, findErr := models.FindEnvVar(context.Background(), testCtx.database, envVar.ID) envVars, err := models.FindEnvVarsByAppID(
require.NoError(t, findErr) context.Background(), testCtx.database, createdApp.ID,
assert.Nil(t, found, "env var should be deleted when using correct route param") )
require.NoError(t, err)
assert.Empty(t, envVars, "all env vars should be deleted")
} }
// TestHandleVolumeAddValidatesPaths verifies that HandleVolumeAdd validates // TestHandleVolumeAddValidatesPaths verifies that HandleVolumeAdd validates

View File

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

View File

@@ -3,7 +3,7 @@ package handlers_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/upaas/internal/handlers" "sneak.berlin/go/upaas/internal/handlers"
) )
func TestSanitizeLogs(t *testing.T) { //nolint:funlen // table-driven tests func TestSanitizeLogs(t *testing.T) { //nolint:funlen // table-driven tests

View File

@@ -3,7 +3,8 @@ package handlers
import ( import (
"net/http" "net/http"
"git.eeqj.de/sneak/upaas/templates" "sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/templates"
) )
const ( const (
@@ -111,6 +112,9 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
return return
} }
h.auditLog(request, models.AuditActionSetup,
models.AuditResourceUser, "", "initial setup completed")
http.Redirect(writer, request, "/", http.StatusSeeOther) http.Redirect(writer, request, "/", http.StatusSeeOther)
} }
} }

View File

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

View File

@@ -6,14 +6,15 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/audit"
) )
// maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB). // maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB).
const maxWebhookBodySize = 1 << 20 const maxWebhookBodySize = 1 << 20
// HandleWebhook handles incoming Gitea webhooks. // HandleWebhook handles incoming Gitea webhooks.
func (h *Handlers) HandleWebhook() http.HandlerFunc { func (h *Handlers) HandleWebhook() http.HandlerFunc { //nolint:funlen // audit logging adds necessary length
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
secret := chi.URLParam(request, "secret") secret := chi.URLParam(request, "secret")
if secret == "" { if secret == "" {
@@ -56,6 +57,15 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
eventType = "push" eventType = "push"
} }
// 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 // Process webhook
webhookErr := h.webhook.HandleWebhook( webhookErr := h.webhook.HandleWebhook(
request.Context(), request.Context(),

View File

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

View File

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

View File

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

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

@@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
) )
//nolint:gosec // test credentials //nolint:gosec // test credentials

View File

@@ -18,10 +18,11 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/service/auth" "sneak.berlin/go/upaas/internal/metrics"
"sneak.berlin/go/upaas/internal/service/auth"
) )
// corsMaxAge is the maximum age for CORS preflight responses in seconds. // corsMaxAge is the maximum age for CORS preflight responses in seconds.
@@ -35,11 +36,13 @@ type Params struct {
Globals *globals.Globals Globals *globals.Globals
Config *config.Config Config *config.Config
Auth *auth.Service Auth *auth.Service
Metrics *metrics.Metrics
} }
// Middleware provides HTTP middleware. // Middleware provides HTTP middleware.
type Middleware struct { type Middleware struct {
log *slog.Logger log *slog.Logger
metrics *metrics.Metrics
params *Params params *Params
} }
@@ -47,21 +50,23 @@ type Middleware struct {
func New(_ fx.Lifecycle, params Params) (*Middleware, error) { func New(_ fx.Lifecycle, params Params) (*Middleware, error) {
return &Middleware{ return &Middleware{
log: params.Logger.Get(), log: params.Logger.Get(),
metrics: params.Metrics,
params: &params, params: &params,
}, nil }, nil
} }
// loggingResponseWriter wraps http.ResponseWriter to capture status code. // loggingResponseWriter wraps http.ResponseWriter to capture status code and bytes written.
type loggingResponseWriter struct { type loggingResponseWriter struct {
http.ResponseWriter http.ResponseWriter
statusCode int statusCode int
bytesWritten int
} }
func newLoggingResponseWriter( func newLoggingResponseWriter(
writer http.ResponseWriter, writer http.ResponseWriter,
) *loggingResponseWriter { ) *loggingResponseWriter {
return &loggingResponseWriter{writer, http.StatusOK} return &loggingResponseWriter{ResponseWriter: writer, statusCode: http.StatusOK}
} }
func (lrw *loggingResponseWriter) WriteHeader(code int) { func (lrw *loggingResponseWriter) WriteHeader(code int) {
@@ -69,7 +74,14 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.ResponseWriter.WriteHeader(code) 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 { func (m *Middleware) Logging() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func( return http.HandlerFunc(func(
@@ -83,6 +95,8 @@ func (m *Middleware) Logging() func(http.Handler) http.Handler {
defer func() { defer func() {
latency := time.Since(start) latency := time.Since(start)
reqID := middleware.GetReqID(ctx) reqID := middleware.GetReqID(ctx)
statusStr := strconv.Itoa(lrw.statusCode)
m.log.InfoContext(ctx, "request", m.log.InfoContext(ctx, "request",
"request_start", start, "request_start", start,
"method", request.Method, "method", request.Method,
@@ -91,10 +105,21 @@ func (m *Middleware) Logging() func(http.Handler) http.Handler {
"request_id", reqID, "request_id", reqID,
"referer", request.Referer(), "referer", request.Referer(),
"proto", request.Proto, "proto", request.Proto,
"remoteIP", realIP(request), "remoteIP", RealIP(request),
"status", lrw.statusCode, "status", lrw.statusCode,
"bytes", lrw.bytesWritten,
"latency_ms", latency.Milliseconds(), "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) next.ServeHTTP(lrw, request)
@@ -145,11 +170,11 @@ func isTrustedProxy(ip net.IP) bool {
return false 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 // Proxy headers (X-Real-IP, X-Forwarded-For) are only trusted when the
// direct connection originates from an RFC1918/loopback address. // direct connection originates from an RFC1918/loopback address.
// Otherwise, headers are ignored and RemoteAddr is used (fail closed). // 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) addr := ipFromHostPort(r.RemoteAddr)
remoteIP := net.ParseIP(addr) remoteIP := net.ParseIP(addr)
@@ -340,7 +365,7 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
ip := realIP(request) ip := RealIP(request)
limiter := loginLimiter.getLimiter(ip) limiter := loginLimiter.getLimiter(ip)
if !limiter.Allow() { if !limiter.Allow() {

View File

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

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 ( import (
"context" "context"
@@ -126,9 +126,9 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
req.Header.Set("X-Forwarded-For", tt.xff) req.Header.Set("X-Forwarded-For", tt.xff)
} }
got := realIP(req) got := RealIP(req)
if got != tt.want { if got != tt.want {
t.Errorf("realIP() = %q, want %q", got, tt.want) t.Errorf("RealIP() = %q, want %q", got, tt.want)
} }
}) })
} }

View File

@@ -7,7 +7,7 @@ import (
"fmt" "fmt"
"time" "time"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
) )
// appColumns is the standard column list for app queries. // appColumns is the standard column list for app queries.

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

@@ -8,7 +8,7 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
) )
// DeploymentStatus represents the status of a deployment. // DeploymentStatus represents the status of a deployment.

View File

@@ -1,4 +1,3 @@
//nolint:dupl // Active Record pattern - similar structure to label.go is intentional
package models package models
import ( import (
@@ -7,7 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
) )
// EnvVar represents an environment variable for an app. // EnvVar represents an environment variable for an app.
@@ -129,13 +128,48 @@ func FindEnvVarsByAppID(
return envVars, rows.Err() return envVars, rows.Err()
} }
// DeleteEnvVarsByAppID deletes all env vars for an app. // EnvVarPair is a key-value pair for bulk env var operations.
func DeleteEnvVarsByAppID( type EnvVarPair struct {
Key string
Value string
}
// ReplaceEnvVarsByAppID atomically replaces all env vars for an app
// within a single database transaction. It deletes all existing env
// vars and inserts the provided pairs. If any operation fails, the
// entire transaction is rolled back.
func ReplaceEnvVarsByAppID(
ctx context.Context, ctx context.Context,
db *database.Database, db *database.Database,
appID string, appID string,
pairs []EnvVarPair,
) error { ) error {
_, err := db.Exec(ctx, "DELETE FROM app_env_vars WHERE app_id = ?", appID) tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err return fmt.Errorf("beginning transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
_, err = tx.ExecContext(ctx, "DELETE FROM app_env_vars WHERE app_id = ?", appID)
if err != nil {
return fmt.Errorf("deleting env vars: %w", err)
}
for _, p := range pairs {
_, err = tx.ExecContext(ctx,
"INSERT INTO app_env_vars (app_id, key, value) VALUES (?, ?, ?)",
appID, p.Key, p.Value,
)
if err != nil {
return fmt.Errorf("inserting env var %q: %w", p.Key, err)
}
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("committing transaction: %w", err)
}
return nil
} }

View File

@@ -1,4 +1,3 @@
//nolint:dupl // Active Record pattern - similar structure to env_var.go is intentional
package models package models
import ( import (
@@ -7,7 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
) )
// Label represents a Docker label for an app container. // Label represents a Docker label for an app container.

View File

@@ -10,11 +10,11 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
) )
// Test constants to satisfy goconst linter. // Test constants to satisfy goconst linter.
@@ -23,6 +23,7 @@ const (
testBranch = "main" testBranch = "main"
testValue = "value" testValue = "value"
testEventType = "push" testEventType = "push"
testAdmin = "admin"
) )
func setupTestDB(t *testing.T) (*database.Database, func()) { func setupTestDB(t *testing.T) (*database.Database, func()) {
@@ -183,7 +184,7 @@ func TestUserExists(t *testing.T) {
defer cleanup() defer cleanup()
user := models.NewUser(testDB) user := models.NewUser(testDB)
user.Username = "admin" user.Username = testAdmin
user.PasswordHash = testHash user.PasswordHash = testHash
err := user.Save(context.Background()) err := user.Save(context.Background())
@@ -781,6 +782,179 @@ func TestCascadeDelete(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. // Helper function to create a test app.
func createTestApp(t *testing.T, testDB *database.Database) *models.App { func createTestApp(t *testing.T, testDB *database.Database) *models.App {
t.Helper() t.Helper()

View File

@@ -6,7 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
) )
// PortProtocol represents the protocol for a port mapping. // PortProtocol represents the protocol for a port mapping.

View File

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

View File

@@ -6,7 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
) )
// Volume represents a volume mount for an app container. // Volume represents a volume mount for an app container.

View File

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

View File

@@ -8,7 +8,7 @@ import (
chimw "github.com/go-chi/chi/v5/middleware" chimw "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"git.eeqj.de/sneak/upaas/static" "sneak.berlin/go/upaas/static"
) )
// requestTimeout is the maximum duration for handling a request. // requestTimeout is the maximum duration for handling a request.
@@ -70,6 +70,7 @@ func (s *Server) SetupRoutes() {
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy()) r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy()) r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments()) r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
r.Get("/apps/{id}/webhooks", s.handlers.HandleAppWebhookEvents())
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI()) r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload()) r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs()) r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
@@ -81,10 +82,8 @@ func (s *Server) SetupRoutes() {
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop()) r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
r.Post("/apps/{id}/start", s.handlers.HandleAppStart()) r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
// Environment variables // Environment variables (monolithic bulk save)
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd()) r.Post("/apps/{id}/env", s.handlers.HandleEnvVarSave())
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
// Labels // Labels
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd()) r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
@@ -116,14 +115,13 @@ func (s *Server) SetupRoutes() {
r.Get("/apps", s.handlers.HandleAPIListApps()) r.Get("/apps", s.handlers.HandleAPIListApps())
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp()) r.Get("/apps/{id}", s.handlers.HandleAPIGetApp())
r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments()) r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments())
r.Get("/audit", s.handlers.HandleAPIAuditLog())
}) })
}) })
// Metrics endpoint (optional, with basic auth) // Metrics endpoint (always available, optionally protected with basic auth)
if s.params.Config.MetricsUsername != "" {
s.router.Group(func(r chi.Router) { s.router.Group(func(r chi.Router) {
r.Use(s.mw.MetricsAuth()) r.Use(s.mw.MetricsAuth())
r.Get("/metrics", promhttp.Handler().ServeHTTP) r.Get("/metrics", promhttp.Handler().ServeHTTP)
}) })
} }
}

View File

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

View File

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

View File

@@ -8,12 +8,12 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/app" "sneak.berlin/go/upaas/internal/service/app"
) )
func setupTestService(t *testing.T) (*app.Service, func()) { func setupTestService(t *testing.T) (*app.Service, func()) {

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

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

View File

@@ -12,11 +12,11 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/service/auth" "sneak.berlin/go/upaas/internal/service/auth"
) )
func setupTestService(t *testing.T) (*auth.Service, func()) { func setupTestService(t *testing.T) (*auth.Service, func()) {

View File

@@ -17,12 +17,13 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker" "sneak.berlin/go/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/metrics"
"git.eeqj.de/sneak/upaas/internal/service/notify" "sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/notify"
) )
// Time constants. // Time constants.
@@ -208,6 +209,7 @@ type ServiceParams struct {
Database *database.Database Database *database.Database
Docker *docker.Client Docker *docker.Client
Notify *notify.Service Notify *notify.Service
Metrics *metrics.Metrics
} }
// activeDeploy tracks a running deployment so it can be cancelled. // activeDeploy tracks a running deployment so it can be cancelled.
@@ -222,6 +224,7 @@ type Service struct {
db *database.Database db *database.Database
docker *docker.Client docker *docker.Client
notify *notify.Service notify *notify.Service
metrics *metrics.Metrics
config *config.Config config *config.Config
params *ServiceParams params *ServiceParams
activeDeploys sync.Map // map[string]*activeDeploy - per-app active deployment tracking activeDeploys sync.Map // map[string]*activeDeploy - per-app active deployment tracking
@@ -235,6 +238,7 @@ func New(lc fx.Lifecycle, params ServiceParams) (*Service, error) {
db: params.Database, db: params.Database,
docker: params.Docker, docker: params.Docker,
notify: params.Notify, notify: params.Notify,
metrics: params.Metrics,
config: params.Config, config: params.Config,
params: &params, params: &params,
} }
@@ -327,6 +331,11 @@ func (svc *Service) Deploy(
} }
defer svc.unlockApp(app.ID) 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 // Set up cancellable context and register as active deploy
deployCtx, cancel := context.WithCancel(ctx) deployCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{}) done := make(chan struct{})
@@ -334,6 +343,7 @@ func (svc *Service) Deploy(
svc.activeDeploys.Store(app.ID, ad) svc.activeDeploys.Store(app.ID, ad)
defer func() { defer func() {
svc.metrics.DeploymentsInFlight.WithLabelValues(app.Name).Dec()
cancel() cancel()
close(done) close(done)
svc.activeDeploys.Delete(app.ID) svc.activeDeploys.Delete(app.ID)
@@ -359,7 +369,7 @@ func (svc *Service) Deploy(
svc.notify.NotifyBuildStart(bgCtx, app, deployment) 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. // Rollback rolls back an app to its previous image.
@@ -467,15 +477,20 @@ func (svc *Service) runBuildAndDeploy(
bgCtx context.Context, bgCtx context.Context,
app *models.App, app *models.App,
deployment *models.Deployment, deployment *models.Deployment,
deployStart time.Time,
) error { ) error {
// Build phase with timeout // Build phase with timeout
imageID, err := svc.buildImageWithTimeout(deployCtx, app, deployment) imageID, err := svc.buildImageWithTimeout(deployCtx, app, deployment)
if err != nil { if err != nil {
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, "") cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, "")
if cancelErr != nil { if cancelErr != nil {
svc.recordDeployMetrics(app.Name, "cancelled", deployStart)
return cancelErr return cancelErr
} }
svc.recordDeployMetrics(app.Name, "failed", deployStart)
return err return err
} }
@@ -486,9 +501,13 @@ func (svc *Service) runBuildAndDeploy(
if err != nil { if err != nil {
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, imageID) cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, imageID)
if cancelErr != nil { if cancelErr != nil {
svc.recordDeployMetrics(app.Name, "cancelled", deployStart)
return cancelErr return cancelErr
} }
svc.recordDeployMetrics(app.Name, "failed", deployStart)
return err return err
} }
@@ -504,11 +523,19 @@ func (svc *Service) runBuildAndDeploy(
// Use context.WithoutCancel to ensure health check completes even if // Use context.WithoutCancel to ensure health check completes even if
// the parent context is cancelled (e.g., HTTP request ends). // 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 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. // buildImageWithTimeout runs the build phase with a timeout.
func (svc *Service) buildImageWithTimeout( func (svc *Service) buildImageWithTimeout(
ctx context.Context, ctx context.Context,
@@ -1163,6 +1190,7 @@ func (svc *Service) checkHealthAfterDelay(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
deployment *models.Deployment, deployment *models.Deployment,
deployStart time.Time,
) { ) {
svc.log.Info( svc.log.Info(
"waiting 60 seconds to check container health", "waiting 60 seconds to check container health",
@@ -1189,6 +1217,8 @@ func (svc *Service) checkHealthAfterDelay(
svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, err) svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, err)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed) _ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
svc.writeLogsToFile(reloadedApp, deployment) svc.writeLogsToFile(reloadedApp, deployment)
svc.recordDeployMetrics(reloadedApp.Name, "failed", deployStart)
svc.metrics.ContainerHealthy.WithLabelValues(reloadedApp.Name).Set(0)
reloadedApp.Status = models.AppStatusError reloadedApp.Status = models.AppStatusError
_ = reloadedApp.Save(ctx) _ = reloadedApp.Save(ctx)
@@ -1200,6 +1230,8 @@ func (svc *Service) checkHealthAfterDelay(
svc.notify.NotifyDeploySuccess(ctx, reloadedApp, deployment) svc.notify.NotifyDeploySuccess(ctx, reloadedApp, deployment)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusSuccess) _ = deployment.MarkFinished(ctx, models.DeploymentStatusSuccess)
svc.writeLogsToFile(reloadedApp, deployment) svc.writeLogsToFile(reloadedApp, deployment)
svc.recordDeployMetrics(reloadedApp.Name, "success", deployStart)
svc.metrics.ContainerHealthy.WithLabelValues(reloadedApp.Name).Set(1)
} else { } else {
svc.log.Warn( svc.log.Warn(
"container unhealthy after 60 seconds", "container unhealthy after 60 seconds",
@@ -1208,6 +1240,8 @@ func (svc *Service) checkHealthAfterDelay(
svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, ErrContainerUnhealthy) svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, ErrContainerUnhealthy)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed) _ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
svc.writeLogsToFile(reloadedApp, deployment) svc.writeLogsToFile(reloadedApp, deployment)
svc.recordDeployMetrics(reloadedApp.Name, "failed", deployStart)
svc.metrics.ContainerHealthy.WithLabelValues(reloadedApp.Name).Set(0)
reloadedApp.Status = models.AppStatusError reloadedApp.Status = models.AppStatusError
_ = reloadedApp.Save(ctx) _ = reloadedApp.Save(ctx)
} }

View File

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

View File

@@ -10,8 +10,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/service/deploy"
) )
func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) { func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) {

View File

@@ -6,10 +6,10 @@ import (
"os" "os"
"testing" "testing"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker" "sneak.berlin/go/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/service/deploy"
) )
func TestBuildContainerOptionsUsesImageID(t *testing.T) { func TestBuildContainerOptionsUsesImageID(t *testing.T) {

View File

@@ -8,9 +8,9 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/docker" "sneak.berlin/go/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
) )
// NewTestService creates a Service with minimal dependencies for testing. // NewTestService creates a Service with minimal dependencies for testing.

View File

@@ -15,8 +15,8 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
) )
// HTTP client timeout. // HTTP client timeout.

View File

@@ -7,14 +7,16 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"strconv"
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/metrics"
"git.eeqj.de/sneak/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/deploy"
) )
// ServiceParams contains dependencies for Service. // ServiceParams contains dependencies for Service.
@@ -24,6 +26,7 @@ type ServiceParams struct {
Logger *logger.Logger Logger *logger.Logger
Database *database.Database Database *database.Database
Deploy *deploy.Service Deploy *deploy.Service
Metrics *metrics.Metrics
} }
// Service provides webhook handling functionality. // Service provides webhook handling functionality.
@@ -31,6 +34,7 @@ type Service struct {
log *slog.Logger log *slog.Logger
db *database.Database db *database.Database
deploy *deploy.Service deploy *deploy.Service
metrics *metrics.Metrics
params *ServiceParams params *ServiceParams
} }
@@ -40,6 +44,7 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
log: params.Logger.Get(), log: params.Logger.Get(),
db: params.Database, db: params.Database,
deploy: params.Deploy, deploy: params.Deploy,
metrics: params.Metrics,
params: &params, params: &params,
}, nil }, nil
} }
@@ -122,6 +127,10 @@ func (svc *Service) HandleWebhook(
"commit", commitSHA, "commit", commitSHA,
) )
svc.metrics.WebhookEventsTotal.WithLabelValues(
app.Name, eventType, strconv.FormatBool(matched),
).Inc()
// If branch matches, trigger deployment // If branch matches, trigger deployment
if matched { if matched {
svc.triggerDeployment(ctx, app, event) svc.triggerDeployment(ctx, app, event)

View File

@@ -8,19 +8,21 @@ import (
"testing" "testing"
"time" "time"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker" "sneak.berlin/go/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/metrics"
"git.eeqj.de/sneak/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/notify" "sneak.berlin/go/upaas/internal/service/deploy"
"git.eeqj.de/sneak/upaas/internal/service/webhook" "sneak.berlin/go/upaas/internal/service/notify"
"sneak.berlin/go/upaas/internal/service/webhook"
) )
type testDeps struct { type testDeps struct {
@@ -63,13 +65,17 @@ func setupTestService(t *testing.T) (*webhook.Service, *database.Database, func(
notifySvc, err := notify.New(fx.Lifecycle(nil), notify.ServiceParams{Logger: deps.logger}) notifySvc, err := notify.New(fx.Lifecycle(nil), notify.ServiceParams{Logger: deps.logger})
require.NoError(t, err) require.NoError(t, err)
metricsInstance := metrics.NewForTest(prometheus.NewRegistry())
deploySvc, err := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{ deploySvc, err := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{
Logger: deps.logger, Config: deps.config, Database: deps.db, Docker: dockerClient, Notify: notifySvc, Logger: deps.logger, Config: deps.config, Database: deps.db, Docker: dockerClient, Notify: notifySvc,
Metrics: metricsInstance,
}) })
require.NoError(t, err) require.NoError(t, err)
svc, err := webhook.New(fx.Lifecycle(nil), webhook.ServiceParams{ svc, err := webhook.New(fx.Lifecycle(nil), webhook.ServiceParams{
Logger: deps.logger, Database: deps.db, Deploy: deploySvc, Logger: deps.logger, Database: deps.db, Deploy: deploySvc,
Metrics: metricsInstance,
}) })
require.NoError(t, err) require.NoError(t, err)

View File

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

View File

@@ -6,6 +6,103 @@
*/ */
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
// ============================================
// Environment Variable Editor Component
// ============================================
Alpine.data("envVarEditor", (appId) => ({
vars: [],
editIdx: -1,
editKey: "",
editVal: "",
appId: appId,
init() {
this.vars = Array.from(this.$el.querySelectorAll(".env-init")).map(
(span) => ({
key: span.dataset.key,
value: span.dataset.value,
}),
);
},
startEdit(i) {
this.editIdx = i;
this.editKey = this.vars[i].key;
this.editVal = this.vars[i].value;
},
saveEdit(i) {
this.vars[i] = { key: this.editKey, value: this.editVal };
this.editIdx = -1;
this.submitAll();
},
removeVar(i) {
if (!window.confirm("Delete this environment variable?")) {
return;
}
this.vars.splice(i, 1);
this.submitAll();
},
addVar(keyEl, valEl) {
const k = keyEl.value.trim();
const v = valEl.value.trim();
if (!k) {
return;
}
this.vars.push({ key: k, value: v });
this.submitAll();
},
submitAll() {
const csrfInput = this.$el.querySelector(
'input[name="gorilla.csrf.Token"]',
);
const csrfToken = csrfInput ? csrfInput.value : "";
fetch("/apps/" + this.appId + "/env", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify(
this.vars.map((e) => ({ key: e.key, value: e.value })),
),
})
.then((res) => {
if (res.ok) {
window.location.reload();
return;
}
res.json()
.then((data) => {
window.alert(
data.error ||
"Failed to save environment variables.",
);
})
.catch(() => {
window.alert(
"Failed to save environment variables.",
);
});
})
.catch(() => {
window.alert(
"Network error: could not save environment variables.",
);
});
},
}));
// ============================================
// App Detail Page Component
// ============================================
Alpine.data("appDetail", (config) => ({ Alpine.data("appDetail", (config) => ({
appId: config.appId, appId: config.appId,
currentDeploymentId: config.initialDeploymentId, currentDeploymentId: config.initialDeploymentId,
@@ -31,14 +128,22 @@ document.addEventListener("alpine:init", () => {
// Set up scroll listeners after DOM is ready // Set up scroll listeners after DOM is ready
this.$nextTick(() => { this.$nextTick(() => {
this._initScrollTracking(this.$refs.containerLogsWrapper, '_containerAutoScroll'); this._initScrollTracking(
this._initScrollTracking(this.$refs.buildLogsWrapper, '_buildAutoScroll'); this.$refs.containerLogsWrapper,
"_containerAutoScroll",
);
this._initScrollTracking(
this.$refs.buildLogsWrapper,
"_buildAutoScroll",
);
}); });
}, },
_schedulePoll() { _schedulePoll() {
if (this._pollTimer) clearTimeout(this._pollTimer); if (this._pollTimer) clearTimeout(this._pollTimer);
const interval = Alpine.store("utils").isDeploying(this.appStatus) ? 1000 : 10000; const interval = Alpine.store("utils").isDeploying(this.appStatus)
? 1000
: 10000;
this._pollTimer = setTimeout(() => { this._pollTimer = setTimeout(() => {
this.fetchAll(); this.fetchAll();
this._schedulePoll(); this._schedulePoll();
@@ -47,18 +152,29 @@ document.addEventListener("alpine:init", () => {
_initScrollTracking(el, flag) { _initScrollTracking(el, flag) {
if (!el) return; if (!el) return;
el.addEventListener('scroll', () => { el.addEventListener(
"scroll",
() => {
this[flag] = Alpine.store("utils").isScrolledToBottom(el); this[flag] = Alpine.store("utils").isScrolledToBottom(el);
}, { passive: true }); },
{ passive: true },
);
}, },
fetchAll() { fetchAll() {
this.fetchAppStatus(); this.fetchAppStatus();
// Only fetch logs when the respective pane is visible // Only fetch logs when the respective pane is visible
if (this.$refs.containerLogsWrapper && this._isElementVisible(this.$refs.containerLogsWrapper)) { if (
this.$refs.containerLogsWrapper &&
this._isElementVisible(this.$refs.containerLogsWrapper)
) {
this.fetchContainerLogs(); this.fetchContainerLogs();
} }
if (this.showBuildLogs && this.$refs.buildLogsWrapper && this._isElementVisible(this.$refs.buildLogsWrapper)) { if (
this.showBuildLogs &&
this.$refs.buildLogsWrapper &&
this._isElementVisible(this.$refs.buildLogsWrapper)
) {
this.fetchBuildLogs(); this.fetchBuildLogs();
} }
this.fetchRecentDeployments(); this.fetchRecentDeployments();
@@ -107,7 +223,9 @@ document.addEventListener("alpine:init", () => {
this.containerStatus = data.status; this.containerStatus = data.status;
if (changed && this._containerAutoScroll) { if (changed && this._containerAutoScroll) {
this.$nextTick(() => { this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper); Alpine.store("utils").scrollToBottom(
this.$refs.containerLogsWrapper,
);
}); });
} }
} catch (err) { } catch (err) {
@@ -128,7 +246,9 @@ document.addEventListener("alpine:init", () => {
this.buildStatus = data.status; this.buildStatus = data.status;
if (changed && this._buildAutoScroll) { if (changed && this._buildAutoScroll) {
this.$nextTick(() => { this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper); Alpine.store("utils").scrollToBottom(
this.$refs.buildLogsWrapper,
);
}); });
} }
} catch (err) { } catch (err) {
@@ -138,7 +258,9 @@ document.addEventListener("alpine:init", () => {
async fetchRecentDeployments() { async fetchRecentDeployments() {
try { try {
const res = await fetch(`/apps/${this.appId}/recent-deployments`); const res = await fetch(
`/apps/${this.appId}/recent-deployments`,
);
const data = await res.json(); const data = await res.json();
this.deployments = data.deployments || []; this.deployments = data.deployments || [];
} catch (err) { } catch (err) {
@@ -171,7 +293,8 @@ document.addEventListener("alpine:init", () => {
get buildStatusBadgeClass() { get buildStatusBadgeClass() {
return ( return (
Alpine.store("utils").statusBadgeClass(this.buildStatus) + " text-xs" Alpine.store("utils").statusBadgeClass(this.buildStatus) +
" text-xs"
); );
}, },

View File

@@ -12,7 +12,8 @@ document.addEventListener("alpine:init", () => {
this.$el.querySelectorAll("[data-time]").forEach((el) => { this.$el.querySelectorAll("[data-time]").forEach((el) => {
const time = el.getAttribute("data-time"); const time = el.getAttribute("data-time");
if (time) { if (time) {
el.textContent = Alpine.store("utils").formatRelativeTime(time); el.textContent =
Alpine.store("utils").formatRelativeTime(time);
} }
}); });
}, 60000); }, 60000);

View File

@@ -26,9 +26,16 @@ document.addEventListener("alpine:init", () => {
this.$nextTick(() => { this.$nextTick(() => {
const wrapper = this.$refs.logsWrapper; const wrapper = this.$refs.logsWrapper;
if (wrapper) { if (wrapper) {
wrapper.addEventListener('scroll', () => { wrapper.addEventListener(
this._autoScroll = Alpine.store("utils").isScrolledToBottom(wrapper); "scroll",
}, { passive: true }); () => {
this._autoScroll =
Alpine.store("utils").isScrolledToBottom(
wrapper,
);
},
{ passive: true },
);
} }
}); });
@@ -59,7 +66,9 @@ document.addEventListener("alpine:init", () => {
// Scroll to bottom only when content changes AND user hasn't scrolled up // Scroll to bottom only when content changes AND user hasn't scrolled up
if (logsChanged && this._autoScroll) { if (logsChanged && this._autoScroll) {
this.$nextTick(() => { this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper); Alpine.store("utils").scrollToBottom(
this.$refs.logsWrapper,
);
}); });
} }

View File

@@ -21,7 +21,9 @@ document.addEventListener("alpine:init", () => {
if (diffSec < 60) return "just now"; if (diffSec < 60) return "just now";
if (diffMin < 60) if (diffMin < 60)
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago"); return (
diffMin + (diffMin === 1 ? " minute ago" : " minutes ago")
);
if (diffHour < 24) if (diffHour < 24)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago"); return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffDay < 7) if (diffDay < 7)
@@ -33,7 +35,8 @@ document.addEventListener("alpine:init", () => {
* Get the badge class for a given status * Get the badge class for a given status
*/ */
statusBadgeClass(status) { statusBadgeClass(status) {
if (status === "running" || status === "success") return "badge-success"; if (status === "running" || status === "success")
return "badge-success";
if (status === "building" || status === "deploying") if (status === "building" || status === "deploying")
return "badge-warning"; return "badge-warning";
if (status === "failed" || status === "error") return "badge-error"; if (status === "failed" || status === "error") return "badge-error";
@@ -72,7 +75,9 @@ document.addEventListener("alpine:init", () => {
*/ */
isScrolledToBottom(el, tolerance = 30) { isScrolledToBottom(el, tolerance = 30) {
if (!el) return true; if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance; return (
el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance
);
}, },
/** /**

View File

@@ -77,7 +77,10 @@
<!-- Webhook URL --> <!-- Webhook URL -->
<div class="card p-6 mb-6"> <div class="card p-6 mb-6">
<h2 class="section-title mb-4">Webhook URL</h2> <div class="flex items-center justify-between mb-4">
<h2 class="section-title">Webhook URL</h2>
<a href="/apps/{{.App.ID}}/webhooks" class="text-primary-600 hover:text-primary-800 text-sm">Event History</a>
</div>
<p class="text-sm text-gray-500 mb-3">Add this URL as a push webhook in your Gitea repository:</p> <p class="text-sm text-gray-500 mb-3">Add this URL as a push webhook in your Gitea repository:</p>
<div class="copy-field" x-data="copyButton('webhook-url')"> <div class="copy-field" x-data="copyButton('webhook-url')">
<code id="webhook-url" class="copy-field-value text-xs">{{.WebhookURL}}</code> <code id="webhook-url" class="copy-field-value text-xs">{{.WebhookURL}}</code>
@@ -98,9 +101,10 @@
</div> </div>
<!-- Environment Variables --> <!-- Environment Variables -->
<div class="card p-6 mb-6"> <div class="card p-6 mb-6" x-data="envVarEditor('{{.App.ID}}')">
<h2 class="section-title mb-4">Environment Variables</h2> <h2 class="section-title mb-4">Environment Variables</h2>
{{if .EnvVars}} {{range .EnvVars}}<span class="env-init hidden" data-key="{{.Key}}" data-value="{{.Value}}"></span>{{end}}
<template x-if="vars.length > 0">
<div class="overflow-x-auto mb-4"> <div class="overflow-x-auto mb-4">
<table class="table"> <table class="table">
<thead class="table-header"> <thead class="table-header">
@@ -111,47 +115,43 @@
</tr> </tr>
</thead> </thead>
<tbody class="table-body"> <tbody class="table-body">
{{range .EnvVars}} <template x-for="(env, idx) in vars" :key="idx">
<tr x-data="{ editing: false }"> <tr>
<template x-if="!editing"> <template x-if="editIdx !== idx">
<td class="font-mono font-medium">{{.Key}}</td> <td class="font-mono font-medium" x-text="env.key"></td>
</template> </template>
<template x-if="!editing"> <template x-if="editIdx !== idx">
<td class="font-mono text-gray-500">{{.Value}}</td> <td class="font-mono text-gray-500" x-text="env.value"></td>
</template> </template>
<template x-if="!editing"> <template x-if="editIdx !== idx">
<td class="text-right"> <td class="text-right">
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button> <button @click="startEdit(idx)" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)"> <button @click="removeVar(idx)" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
{{ $.CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form>
</td> </td>
</template> </template>
<template x-if="editing"> <template x-if="editIdx === idx">
<td colspan="3"> <td colspan="3">
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/edit" class="flex gap-2 items-center"> <form @submit.prevent="saveEdit(idx)" class="flex gap-2 items-center">
{{ $.CSRFField }} <input type="text" x-model="editKey" required class="input flex-1 font-mono text-sm">
<input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm"> <input type="text" x-model="editVal" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary text-sm">Save</button> <button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button> <button type="button" @click="editIdx = -1" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form> </form>
<p class="text-xs text-amber-600 mt-1">⚠ Container restart needed after env var changes.</p> <p class="text-xs text-amber-600 mt-1">⚠ Container restart needed after env var changes.</p>
</td> </td>
</template> </template>
</tr> </tr>
{{end}} </template>
</tbody> </tbody>
</table> </table>
</div> </div>
{{end}} </template>
<form method="POST" action="/apps/{{.App.ID}}/env" class="flex flex-col sm:flex-row gap-2"> <form @submit.prevent="addVar($refs.newKey, $refs.newVal)" class="flex flex-col sm:flex-row gap-2">
{{ .CSRFField }} <input x-ref="newKey" type="text" placeholder="KEY" required class="input flex-1 font-mono text-sm">
<input type="text" name="key" placeholder="KEY" required class="input flex-1 font-mono text-sm"> <input x-ref="newVal" type="text" placeholder="value" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" placeholder="value" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary">Add</button> <button type="submit" class="btn-primary">Add</button>
</form> </form>
<div class="hidden">{{ .CSRFField }}</div>
</div> </div>
<!-- Labels --> <!-- Labels -->

View File

@@ -44,6 +44,7 @@ func initTemplates() {
"app_detail.html", "app_detail.html",
"app_edit.html", "app_edit.html",
"deployments.html", "deployments.html",
"webhook_events.html",
} }
pageTemplates = make(map[string]*template.Template) pageTemplates = make(map[string]*template.Template)

View File

@@ -0,0 +1,79 @@
{{template "base" .}}
{{define "title"}}Webhook Events - {{.App.Name}} - µPaaS{{end}}
{{define "content"}}
{{template "nav" .}}
<main class="max-w-4xl mx-auto px-4 py-8">
<div class="mb-6">
<a href="/apps/{{.App.ID}}" class="text-primary-600 hover:text-primary-800 inline-flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to {{.App.Name}}
</a>
</div>
<div class="section-header">
<h1 class="text-2xl font-medium text-gray-900">Webhook Events</h1>
</div>
{{if .Events}}
<div class="card overflow-hidden">
<table class="table">
<thead class="table-header">
<tr>
<th>Time</th>
<th>Event</th>
<th>Branch</th>
<th>Commit</th>
<th>Status</th>
</tr>
</thead>
<tbody class="table-body">
{{range .Events}}
<tr>
<td class="text-gray-500 text-sm whitespace-nowrap">
<span x-data="relativeTime('{{.CreatedAt.Format `2006-01-02T15:04:05Z07:00`}}')" x-text="display" class="cursor-default" title="{{.CreatedAt.Format `2006-01-02 15:04:05`}}"></span>
</td>
<td class="text-gray-700 text-sm">{{.EventType}}</td>
<td class="font-mono text-gray-500 text-sm">{{.Branch}}</td>
<td class="font-mono text-gray-500 text-xs">
{{if and .CommitSHA.Valid .CommitURL.Valid}}
<a href="{{.CommitURL.String}}" target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:text-primary-800">{{.ShortCommit}}</a>
{{else if .CommitSHA.Valid}}
{{.ShortCommit}}
{{else}}
<span class="text-gray-400">-</span>
{{end}}
</td>
<td>
{{if .Matched}}
{{if .Processed}}
<span class="badge-success">Matched</span>
{{else}}
<span class="badge-warning">Matched (pending)</span>
{{end}}
{{else}}
<span class="badge-neutral">No match</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card">
<div class="empty-state">
<svg class="empty-state-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<h3 class="empty-state-title">No webhook events yet</h3>
<p class="empty-state-description">Webhook events will appear here once your repository sends push notifications.</p>
</div>
</div>
{{end}}
</main>
{{end}}