Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d87d386c3 | ||
|
|
f65e3887b2 |
@@ -1,10 +0,0 @@
|
||||
.git
|
||||
bin/
|
||||
.editorconfig
|
||||
.vscode/
|
||||
.idea/
|
||||
*.test
|
||||
LICENSE
|
||||
CONVENTIONS.md
|
||||
REPO_POLICIES.md
|
||||
README.md
|
||||
@@ -1,15 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -10,7 +10,17 @@ jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4, 2024-10-13
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Build (runs make check inside Dockerfile)
|
||||
run: script/cibuild
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install golangci-lint
|
||||
run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
|
||||
- name: Install goimports
|
||||
run: go install golang.org/x/tools/cmd/goimports@latest
|
||||
|
||||
- name: Run make check
|
||||
run: make check
|
||||
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editors
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.bak
|
||||
.idea/
|
||||
.vscode/
|
||||
*.sublime-*
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Go
|
||||
bin/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
+14
-80
@@ -1,98 +1,32 @@
|
||||
version: "2"
|
||||
|
||||
# Config schema uses the golangci-lint v2 layout (settings live under
|
||||
# linters.settings, not top-level linters-settings) so that the
|
||||
# thresholds below are actually applied by golangci-lint >= v2.
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
modules-download-mode: readonly
|
||||
|
||||
linters:
|
||||
default: all
|
||||
enable:
|
||||
# Successor to the deprecated gomodguard. Named explicitly, rather than
|
||||
# left to `default: all`, because it carries the module policy below.
|
||||
- gomodguard_v2
|
||||
disable:
|
||||
# Genuinely incompatible with project patterns
|
||||
- exhaustruct # Requires all struct fields
|
||||
- depguard # Dependency allow/block lists
|
||||
- godot # Requires comments to end with periods
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- wrapcheck # Too verbose for internal packages
|
||||
- varnamelen # Short names like db, id are idiomatic Go
|
||||
# Deprecated: the warning is attached to the old name, so it is
|
||||
# silenced by disabling that name, not by enabling the successor.
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||
settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
funlen:
|
||||
lines: 80
|
||||
statements: 50
|
||||
cyclop:
|
||||
max-complexity: 15
|
||||
dupl:
|
||||
threshold: 100
|
||||
depguard:
|
||||
# Test-support code must not be compiled into the shipped binary. A
|
||||
# test-support package exists to hand a test privileges the program
|
||||
# itself must never have, so a file that is not a test must not import
|
||||
# one. Test files, and the files inside a package whose directory name
|
||||
# ends in `test`, are where that code belongs, and are exempt.
|
||||
#
|
||||
# The deny list below is the one part of this file a repository is
|
||||
# expected to extend, and the only part it may. depguard matches an
|
||||
# import path against a list of prefixes, so it cannot be told "any path
|
||||
# whose last segment ends in test"; a repository's own test-support
|
||||
# packages have to be named here one at a time, by full import path,
|
||||
# under a module path that differs from repository to repository. Add
|
||||
# them; change nothing else.
|
||||
rules:
|
||||
test-support:
|
||||
list-mode: lax
|
||||
files:
|
||||
- "$all"
|
||||
- "!$test"
|
||||
- "!**/*test/**"
|
||||
deny:
|
||||
- pkg: net/http/httptest
|
||||
desc: >-
|
||||
Test-support code belongs in test files and in packages whose
|
||||
directory name ends in test, not in the shipped binary.
|
||||
# Only decisions already recorded in the Go package defaults are
|
||||
# listed here. Every entry matches the module path exactly.
|
||||
gomodguard_v2:
|
||||
blocked:
|
||||
- module: github.com/rs/zerolog
|
||||
recommendations:
|
||||
- log/slog
|
||||
reason: "Structured logging is stdlib log/slog."
|
||||
# One entry per pre-fork module path, because the later releases
|
||||
# are separate paths. A prefix match would be shorter but would
|
||||
# also reach github.com/go-redis/redismock, the test double for
|
||||
# the successor these entries recommend.
|
||||
- module: github.com/go-redis/redis
|
||||
recommendations:
|
||||
- github.com/redis/go-redis/v9
|
||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||
- module: github.com/go-redis/redis/v7
|
||||
recommendations:
|
||||
- github.com/redis/go-redis/v9
|
||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||
- module: github.com/go-redis/redis/v8
|
||||
recommendations:
|
||||
- github.com/redis/go-redis/v9
|
||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||
- module: github.com/sergi/go-diff
|
||||
recommendations:
|
||||
- github.com/aymanbagabas/go-udiff
|
||||
reason: "No unified diff output; use go-udiff."
|
||||
- module: github.com/hexops/gotextdiff
|
||||
recommendations:
|
||||
- github.com/aymanbagabas/go-udiff
|
||||
reason: "Unmaintained fork; use go-udiff."
|
||||
|
||||
linters-settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
funlen:
|
||||
lines: 80
|
||||
statements: 50
|
||||
cyclop:
|
||||
max-complexity: 15
|
||||
dupl:
|
||||
threshold: 100
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
node_modules/
|
||||
yarn.lock
|
||||
|
||||
# Vendored, minified third-party bundles must never be reformatted.
|
||||
*.min.js
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"proseWrap": "always"
|
||||
}
|
||||
+31
-37
@@ -1,8 +1,6 @@
|
||||
# Go HTTP Server Conventions
|
||||
|
||||
This document defines the architectural patterns, design decisions, and
|
||||
conventions for building Go HTTP servers. All new projects must follow these
|
||||
standards.
|
||||
This document defines the architectural patterns, design decisions, and conventions for building Go HTTP servers. All new projects must follow these standards.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -27,18 +25,18 @@ standards.
|
||||
|
||||
These libraries are **mandatory** for all new projects:
|
||||
|
||||
| Purpose | Library | Import Path |
|
||||
| -------------------- | --------------- | ------------------------------------- |
|
||||
| Dependency Injection | Uber fx | `go.uber.org/fx` |
|
||||
| HTTP Router | go-chi | `github.com/go-chi/chi` |
|
||||
| Logging | slog (stdlib) | `log/slog` |
|
||||
| Configuration | Viper | `github.com/spf13/viper` |
|
||||
| Environment Loading | godotenv | `github.com/joho/godotenv/autoload` |
|
||||
| CORS | go-chi/cors | `github.com/go-chi/cors` |
|
||||
| Error Reporting | Sentry | `github.com/getsentry/sentry-go` |
|
||||
| Metrics | Prometheus | `github.com/prometheus/client_golang` |
|
||||
| Metrics Middleware | go-http-metrics | `github.com/slok/go-http-metrics` |
|
||||
| Basic Auth | basicauth-go | `github.com/99designs/basicauth-go` |
|
||||
| Purpose | Library | Import Path |
|
||||
|---------|---------|-------------|
|
||||
| Dependency Injection | Uber fx | `go.uber.org/fx` |
|
||||
| HTTP Router | go-chi | `github.com/go-chi/chi` |
|
||||
| Logging | slog (stdlib) | `log/slog` |
|
||||
| Configuration | Viper | `github.com/spf13/viper` |
|
||||
| Environment Loading | godotenv | `github.com/joho/godotenv/autoload` |
|
||||
| CORS | go-chi/cors | `github.com/go-chi/cors` |
|
||||
| Error Reporting | Sentry | `github.com/getsentry/sentry-go` |
|
||||
| Metrics | Prometheus | `github.com/prometheus/client_golang` |
|
||||
| Metrics Middleware | go-http-metrics | `github.com/slok/go-http-metrics` |
|
||||
| Basic Auth | basicauth-go | `github.com/99designs/basicauth-go` |
|
||||
|
||||
---
|
||||
|
||||
@@ -87,8 +85,7 @@ project-root/
|
||||
### Key Principles
|
||||
|
||||
- **`cmd/{appname}/`**: Only the entry point. Minimal logic, just bootstrapping.
|
||||
- **`internal/`**: All application packages. Not importable by external
|
||||
projects.
|
||||
- **`internal/`**: All application packages. Not importable by external projects.
|
||||
- **One package per concern**: config, database, handlers, middleware, etc.
|
||||
- **Flat handler files**: One file per handler or logical group of handlers.
|
||||
|
||||
@@ -193,8 +190,7 @@ Providers are resolved automatically by fx, but conceptually follow this order:
|
||||
2. `logger.New` - Logger (depends on Globals)
|
||||
3. `config.New` - Configuration (depends on Globals, Logger)
|
||||
4. `database.New` - Database (depends on Logger, Config)
|
||||
5. `healthcheck.New` - Health check (depends on Globals, Config, Logger,
|
||||
Database)
|
||||
5. `healthcheck.New` - Health check (depends on Globals, Config, Logger, Database)
|
||||
6. `middleware.New` - Middleware (depends on Logger, Globals, Config)
|
||||
7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck)
|
||||
8. `server.New` - Server (depends on all above)
|
||||
@@ -457,8 +453,7 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
|
||||
|
||||
### Closure-Based Handler Pattern
|
||||
|
||||
All handlers return `http.HandlerFunc` using the closure pattern. This allows
|
||||
initialization logic to run once when the handler is created:
|
||||
All handlers return `http.HandlerFunc` using the closure pattern. This allows initialization logic to run once when the handler is created:
|
||||
|
||||
```go
|
||||
// internal/handlers/index.go
|
||||
@@ -515,8 +510,7 @@ func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interfac
|
||||
### Handler Naming Convention
|
||||
|
||||
- `HandleIndex()` - Main page
|
||||
- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method
|
||||
suffix
|
||||
- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method suffix
|
||||
- `HandleNow()` - API endpoints
|
||||
- `HandleHealthCheck()` - System endpoints
|
||||
|
||||
@@ -739,8 +733,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
|
||||
1. **Environment variables** (highest priority via `AutomaticEnv()`)
|
||||
2. **`.env` file** (loaded via `godotenv/autoload` import)
|
||||
3. **Config files**: `/etc/{appname}/{appname}.yaml`,
|
||||
`~/.config/{appname}/{appname}.yaml`
|
||||
3. **Config files**: `/etc/{appname}/{appname}.yaml`, `~/.config/{appname}/{appname}.yaml`
|
||||
4. **Defaults** (lowest priority)
|
||||
|
||||
### Environment Loading
|
||||
@@ -1012,7 +1005,6 @@ var Static embed.FS
|
||||
```
|
||||
|
||||
Directory structure:
|
||||
|
||||
```
|
||||
static/
|
||||
├── static.go
|
||||
@@ -1053,13 +1045,15 @@ Templates use Go's template composition:
|
||||
|
||||
```html
|
||||
<!-- index.html -->
|
||||
{{ template "htmlheader.html" . }} {{ template "navbar.html" . }}
|
||||
{{ template "htmlheader.html" . }}
|
||||
{{ template "navbar.html" . }}
|
||||
|
||||
<main>
|
||||
<!-- Page content -->
|
||||
</main>
|
||||
|
||||
{{ template "pagefooter.html" . }} {{ template "htmlfooter.html" . }}
|
||||
{{ template "pagefooter.html" . }}
|
||||
{{ template "htmlfooter.html" . }}
|
||||
```
|
||||
|
||||
### Static Asset References
|
||||
@@ -1220,12 +1214,12 @@ if viper.GetString("METRICS_USERNAME") != "" {
|
||||
|
||||
### Environment Variables Summary
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------------ | -------------------------------- | ------- |
|
||||
| `PORT` | HTTP listen port | 8080 |
|
||||
| `DEBUG` | Enable debug logging | false |
|
||||
| `DBURL` | Database connection URL | "" |
|
||||
| `SENTRY_DSN` | Sentry DSN for error reporting | "" |
|
||||
| `MAINTENANCE_MODE` | Enable maintenance mode | false |
|
||||
| `METRICS_USERNAME` | Basic auth username for /metrics | "" |
|
||||
| `METRICS_PASSWORD` | Basic auth password for /metrics | "" |
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PORT` | HTTP listen port | 8080 |
|
||||
| `DEBUG` | Enable debug logging | false |
|
||||
| `DBURL` | Database connection URL | "" |
|
||||
| `SENTRY_DSN` | Sentry DSN for error reporting | "" |
|
||||
| `MAINTENANCE_MODE` | Enable maintenance mode | false |
|
||||
| `METRICS_USERNAME` | Basic auth username for /metrics | "" |
|
||||
| `METRICS_PASSWORD` | Basic auth password for /metrics | "" |
|
||||
|
||||
+11
-26
@@ -1,41 +1,26 @@
|
||||
# Lint stage — fast feedback on formatting and lint issues
|
||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# golangci-lint is invoked directly here, not via `make lint`: script/lint
|
||||
# now runs the linter by building Dockerfile.lint, and shelling out to
|
||||
# `docker build` from inside this image build would be docker-in-docker.
|
||||
# This image is golangci/golangci-lint, so the pinned linter is on PATH.
|
||||
RUN make fmt-check
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# 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
|
||||
# Build stage
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make gcc musl-dev
|
||||
|
||||
# Install golangci-lint v2
|
||||
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||
RUN go install golang.org/x/tools/cmd/goimports@latest
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN make test
|
||||
# Run all checks - build fails if any check fails
|
||||
RUN make check
|
||||
|
||||
# Build the binary
|
||||
RUN make build
|
||||
|
||||
# Runtime stage
|
||||
# alpine:3.19
|
||||
FROM alpine@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata git openssh-client docker-cli
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Lint image — runs golangci-lint inside a container so every lint uses
|
||||
# the pinned linter, never a host binary. Linting is a build step, so a
|
||||
# successful build is a clean lint. Built by script/lint.
|
||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Caching is waived for linting: on an unchanged tree a cached build runs
|
||||
# no linter and still exits 0 in under a second. script/lint passes a
|
||||
# fresh GATE_RUN every time, and referencing it here forces this step to
|
||||
# re-run, so the linter always executes.
|
||||
#
|
||||
# `golangci-lint config verify` is deliberately NOT run: it fetches its
|
||||
# JSON schema over an unpinned live HTTPS call, which REPO_POLICIES.md
|
||||
# forbids (all external references must be pinned by hash).
|
||||
ARG GATE_RUN
|
||||
RUN echo "lint run: ${GATE_RUN}"; golangci-lint run --config .golangci.yml ./...
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all bootstrap setup build lint fmt fmt-check test check clean docker hooks
|
||||
.PHONY: all build lint fmt test check clean
|
||||
|
||||
BINARY := upaasd
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
@@ -7,37 +7,32 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
||||
|
||||
all: check build
|
||||
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
|
||||
setup:
|
||||
@script/setup
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/upaasd
|
||||
|
||||
lint:
|
||||
@script/lint
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
fmt:
|
||||
@script/fmt
|
||||
|
||||
fmt-check:
|
||||
@script/fmt-check
|
||||
gofmt -s -w .
|
||||
goimports -w .
|
||||
npx prettier --write --tab-width 4 static/js/*.js
|
||||
|
||||
test:
|
||||
@script/test
|
||||
go test -v -race -cover ./...
|
||||
|
||||
# Check runs all validation without making changes
|
||||
# Used by CI and Docker build - fails if anything is wrong
|
||||
check:
|
||||
@script/check
|
||||
|
||||
docker:
|
||||
@script/docker
|
||||
|
||||
hooks:
|
||||
@script/install-precommit
|
||||
@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!"
|
||||
|
||||
clean:
|
||||
rm -rf bin/
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
# µPaaS by [@sneak](https://sneak.berlin)
|
||||
|
||||
A simple self-hosted PaaS that auto-deploys Docker containers from Git
|
||||
repositories via webhooks from Gitea, GitHub, or GitLab.
|
||||
A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via Gitea webhooks.
|
||||
|
||||
## Features
|
||||
|
||||
- Single admin user with argon2id password hashing
|
||||
- Per-app SSH keypairs for read-only deploy keys
|
||||
- Per-app UUID-based webhook URLs with auto-detection of Gitea, GitHub, and
|
||||
GitLab
|
||||
- Per-app UUID-based webhook URLs for Gitea integration
|
||||
- Branch filtering - only deploy on configured branch changes
|
||||
- Environment variables, labels, and volume mounts per app
|
||||
- CPU and memory resource limits per app
|
||||
- Docker builds via socket access
|
||||
- Notifications via ntfy and Slack-compatible webhooks
|
||||
- Simple server-rendered UI with Tailwind CSS
|
||||
@@ -22,7 +19,7 @@ repositories via webhooks from Gitea, GitHub, or GitLab.
|
||||
- Complex CI pipelines
|
||||
- Multiple container orchestration
|
||||
- SPA/API-first design
|
||||
- Support for non-push webhook events (e.g. issues, merge requests)
|
||||
- Support for non-Gitea webhooks
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -47,7 +44,7 @@ upaas/
|
||||
│ │ ├── auth/ # Authentication service
|
||||
│ │ ├── deploy/ # Deployment orchestration
|
||||
│ │ ├── notify/ # Notifications (ntfy, Slack)
|
||||
│ │ └── webhook/ # Webhook processing (Gitea, GitHub, GitLab)
|
||||
│ │ └── webhook/ # Gitea webhook processing
|
||||
│ └── ssh/ # SSH key generation
|
||||
├── static/ # Embedded CSS/JS assets
|
||||
└── templates/ # Embedded HTML templates
|
||||
@@ -97,39 +94,11 @@ chi Router ──► Middleware Stack ──► Handler
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **Closure-based handlers**: Handlers return `http.HandlerFunc` allowing
|
||||
one-time initialization
|
||||
- **Active Record models**: Models encapsulate database operations (`Save()`,
|
||||
`Delete()`, `Reload()`)
|
||||
- **Async deployments**: Webhook triggers deploy via goroutine with
|
||||
`context.WithoutCancel()`
|
||||
- **Closure-based handlers**: Handlers return `http.HandlerFunc` allowing one-time initialization
|
||||
- **Active Record models**: Models encapsulate database operations (`Save()`, `Delete()`, `Reload()`)
|
||||
- **Async deployments**: Webhook triggers deploy via goroutine with `context.WithoutCancel()`
|
||||
- **Embedded assets**: Templates and static files embedded via `//go:embed`
|
||||
|
||||
## Entrypoints
|
||||
|
||||
This repository adheres to the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
standard: normalized scripts in `script/` are the entrypoints for the
|
||||
development workflow, and the Makefile targets are thin shims that call them. We
|
||||
provide:
|
||||
|
||||
- `script/bootstrap` — install all dependencies (idempotent)
|
||||
- `script/setup` — make a fresh clone ready for development (bootstrap, then
|
||||
install-precommit)
|
||||
- `script/projectname` — output the project name ("upaas")
|
||||
- `script/test` — run the test suite
|
||||
- `script/lint` — run golangci-lint
|
||||
- `script/fmt` — format all code (writes)
|
||||
- `script/fmt-check` — check formatting (read-only)
|
||||
- `script/check` — run test, lint, and fmt-check
|
||||
- `script/docker` — build the Docker image tagged via `script/projectname`
|
||||
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile runs the
|
||||
checks, so a green build implies a green repo)
|
||||
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
||||
`script/check`)
|
||||
- `script/install-precommit` — install the git pre-commit hook that runs
|
||||
`script/precommit`
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
@@ -141,16 +110,11 @@ provide:
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
make bootstrap # Install all dependencies (idempotent)
|
||||
make setup # Bootstrap + install git pre-commit hook
|
||||
make fmt # Format code
|
||||
make fmt-check # Check formatting (read-only, fails if unformatted)
|
||||
make lint # Run comprehensive linting
|
||||
make test # Run tests with race detection (30s timeout)
|
||||
make check # Verify everything passes (test, lint, fmt-check)
|
||||
make build # Build binary
|
||||
make docker # Build Docker image
|
||||
make hooks # Install pre-commit hook (runs script/precommit)
|
||||
make fmt # Format code
|
||||
make lint # Run comprehensive linting
|
||||
make test # Run tests with race detection
|
||||
make check # Verify everything passes (lint, test, build, format)
|
||||
make build # Build binary
|
||||
```
|
||||
|
||||
### Commit Requirements
|
||||
@@ -161,11 +125,11 @@ Before every commit:
|
||||
|
||||
1. **Format**: Run `make fmt` to format all code
|
||||
2. **Lint**: Run `make lint` and fix all errors/warnings
|
||||
- Do not disable linters or add nolint comments without good reason
|
||||
- Fix the code, don't hide the problem
|
||||
- Do not disable linters or add nolint comments without good reason
|
||||
- Fix the code, don't hide the problem
|
||||
3. **Test**: Run `make test` and ensure all tests pass
|
||||
- Fix failing tests by fixing the code, not by modifying tests to pass
|
||||
- Add tests for new functionality
|
||||
- Fix failing tests by fixing the code, not by modifying tests to pass
|
||||
- Add tests for new functionality
|
||||
4. **Verify**: Run `make check` to confirm everything passes
|
||||
|
||||
```bash
|
||||
@@ -179,7 +143,6 @@ git commit -m "Your message"
|
||||
```
|
||||
|
||||
The Docker build runs `make check` and will fail if:
|
||||
|
||||
- Code is not formatted
|
||||
- Linting errors exist
|
||||
- Tests fail
|
||||
@@ -191,17 +154,16 @@ This ensures the main branch always contains clean, tested, working code.
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| `PORT` | HTTP listen port | 8080 |
|
||||
| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | `./data` (local dev only — use absolute path for Docker) |
|
||||
| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | _(none — must be set to an absolute path)_ |
|
||||
| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock |
|
||||
| `UPAAS_PLAINTEXT_HTTP` | Set when µPaaS is reached over plain HTTP (no TLS-terminating proxy in front) so CSRF origin checks use `http://`. Leave unset behind a TLS-terminating reverse proxy. | false |
|
||||
| `DEBUG` | Enable debug logging | false |
|
||||
| `SENTRY_DSN` | Sentry error reporting DSN | "" |
|
||||
| `METRICS_USERNAME` | Basic auth for /metrics | "" |
|
||||
| `METRICS_PASSWORD` | Basic auth for /metrics | "" |
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PORT` | HTTP listen port | 8080 |
|
||||
| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | ./data |
|
||||
| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | same as DATA_DIR |
|
||||
| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock |
|
||||
| `DEBUG` | Enable debug logging | false |
|
||||
| `SENTRY_DSN` | Sentry error reporting DSN | "" |
|
||||
| `METRICS_USERNAME` | Basic auth for /metrics | "" |
|
||||
| `METRICS_PASSWORD` | Basic auth for /metrics | "" |
|
||||
|
||||
## Running with Docker
|
||||
|
||||
@@ -211,55 +173,13 @@ docker run -d \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /path/on/host/upaas-data:/var/lib/upaas \
|
||||
-e UPAAS_HOST_DATA_DIR=/path/on/host/upaas-data \
|
||||
-e UPAAS_PLAINTEXT_HTTP=true \
|
||||
upaas
|
||||
```
|
||||
|
||||
This recipe serves plain HTTP, so `UPAAS_PLAINTEXT_HTTP=true` is required for
|
||||
setup and every other form to pass the CSRF origin check. Behind a
|
||||
TLS-terminating reverse proxy, drop that line.
|
||||
**Important**: When running µPaaS inside a container, set `UPAAS_HOST_DATA_DIR` to the host path
|
||||
that maps to `UPAAS_DATA_DIR`. This is required for Docker bind mounts during builds to work correctly.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
upaas:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${HOST_DATA_DIR}:/var/lib/upaas
|
||||
environment:
|
||||
- UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR}
|
||||
# Set when serving plain HTTP (no TLS-terminating proxy); drop behind one
|
||||
- UPAAS_PLAINTEXT_HTTP=true
|
||||
# Optional: uncomment to enable debug logging
|
||||
# - DEBUG=true
|
||||
# Optional: Sentry error reporting
|
||||
# - SENTRY_DSN=https://...
|
||||
# Optional: Prometheus metrics auth
|
||||
# - METRICS_USERNAME=prometheus
|
||||
# - METRICS_PASSWORD=secret
|
||||
```
|
||||
|
||||
**Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the
|
||||
host before running `docker compose up`. This value is bind-mounted into the
|
||||
container and passed as `UPAAS_HOST_DATA_DIR` so that Docker bind mounts during
|
||||
builds resolve correctly. Relative paths (e.g. `./data`) will break container
|
||||
builds because the Docker daemon resolves paths relative to the host, not the
|
||||
container.
|
||||
|
||||
Example: `HOST_DATA_DIR=/srv/upaas/data docker compose up -d`
|
||||
|
||||
Apps are built with BuildKit, so the stages of a multi-stage build are kept in
|
||||
Docker's build cache rather than as untagged images. Docker Engine 28.2 and
|
||||
later keeps that cache under a size limit by default; on older engines, set
|
||||
`"builder": {"gc": {"enabled": true}}` in the host's `daemon.json`.
|
||||
|
||||
Session secrets are automatically generated on first startup and persisted to
|
||||
`$UPAAS_DATA_DIR/session.key`.
|
||||
Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-07-06
|
||||
---
|
||||
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
style conventions are in separate documents:
|
||||
|
||||
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
||||
(general, bash, Docker)
|
||||
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
||||
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
||||
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
||||
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
||||
|
||||
---
|
||||
|
||||
- Cross-project documentation (such as this file) must include
|
||||
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
|
||||
with the authoritative source as policies evolve.
|
||||
|
||||
- **ALL external references must be pinned by cryptographic hash.** This
|
||||
includes Docker base images, Go modules, npm packages, GitHub Actions, and
|
||||
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
|
||||
`:3.21`, etc.) are server-mutable and therefore remote code execution
|
||||
vulnerabilities. The ONLY acceptable way to reference an external dependency
|
||||
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
|
||||
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
|
||||
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
|
||||
etc. Instead, download a specific release archive from GitHub, verify its hash
|
||||
(hardcoded in the Dockerfile or script), and only then install. Unverified
|
||||
install scripts are arbitrary remote code execution. This is the single most
|
||||
important rule in this document. Double-check every external reference in
|
||||
every file before committing. There are zero exceptions to this rule.
|
||||
|
||||
- Every repo with software must have a root `Makefile` with these targets:
|
||||
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
|
||||
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
|
||||
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
|
||||
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
||||
|
||||
- Repos follow the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
pattern: the implementation of each Makefile target lives in an executable
|
||||
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
|
||||
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
|
||||
`script/docker`), and the Makefile targets are thin shims that call them. The
|
||||
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
|
||||
minimal containers (e.g. alpine images have no bash); locate the repo root
|
||||
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
|
||||
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
|
||||
for development after a fresh clone: runs `bootstrap`, then
|
||||
`install-precommit`, plus any repo-specific initialization), `test`, and
|
||||
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
||||
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
||||
(detected in that order; apt runs noninteractive). For node it uses the
|
||||
installed node if present; otherwise it installs a PINNED node version via
|
||||
nvm, first installing nvm itself if missing — from a hash-verified GitHub
|
||||
release archive (never `curl | sh`), with bash installed as an explicit
|
||||
prerequisite since nvm requires bash. yarn is then pinned via
|
||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
|
||||
scripts are our own extensions to the standard: `script/check` runs
|
||||
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
||||
what the git pre-commit hook runs, and it calls `script/check`;
|
||||
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
||||
target shims to it); and `script/projectname` (literally that filename) simply
|
||||
outputs the project's name. Scripts that need the name call
|
||||
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
||||
so those scripts stay byte-identical across all repos. Repo-type-specific
|
||||
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
||||
`script/precommit`, not in the hook itself. Model scripts are at
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||
must document the provided scripts in an **Entrypoints** section (see the
|
||||
README requirements below).
|
||||
|
||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||
instead of invoking the underlying tools directly. The Makefile is the single
|
||||
source of truth for how these operations are run.
|
||||
|
||||
- The Makefile is authoritative documentation for how the repo is used. Beyond
|
||||
the required targets above, it should have targets for every common operation:
|
||||
running a local development server (`make run`, `make dev`), re-initializing
|
||||
or migrating the database (`make db-reset`, `make migrate`), building
|
||||
artifacts (`make build`), generating code, seeding data, or anything else a
|
||||
developer would do regularly. If someone checks out the repo and types
|
||||
`make<tab>`, they should see every meaningful operation available. A new
|
||||
contributor should be able to understand the entire development workflow by
|
||||
reading the Makefile.
|
||||
|
||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
||||
as a build step so the build fails if the branch is not green. For non-server
|
||||
repos, the Dockerfile should bring up a development environment and run
|
||||
`make check`. For server repos, `make check` should run as an early build
|
||||
stage before the final image is assembled. Dockerfiles install development
|
||||
prerequisites by running `script/bootstrap` rather than duplicating installs
|
||||
inline; COPY `script/` and the dependency manifests (`package.json` +
|
||||
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
||||
layer stays cached until dependencies change.
|
||||
|
||||
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
||||
repos use a multistage build where linting runs in an independent stage based
|
||||
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
||||
`make fmt-check` and `make lint` before the full build begins. The build stage
|
||||
then declares an explicit dependency on the lint stage via
|
||||
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
|
||||
linting before proceeding to compilation and tests. This ensures lint failures
|
||||
surface in seconds rather than minutes, without blocking on dependency
|
||||
download or compilation in the build stage.
|
||||
|
||||
The standard pattern for a Go repo Dockerfile is:
|
||||
|
||||
```dockerfile
|
||||
# Lint stage — fast feedback on formatting and lint issues
|
||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||
FROM golangci/golangci-lint@sha256:... AS lint
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN make fmt-check
|
||||
RUN make lint
|
||||
|
||||
# Build stage
|
||||
# golang:1.x-alpine, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS builder
|
||||
WORKDIR /src
|
||||
|
||||
# Force BuildKit to run the lint stage before proceeding
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN make test
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o /app ./cmd/app/
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine@sha256:...
|
||||
COPY --from=builder /app /usr/local/bin/app
|
||||
ENTRYPOINT ["app"]
|
||||
```
|
||||
|
||||
Key points:
|
||||
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
||||
includes both Go and the linter), so there is no need to install the
|
||||
linter separately.
|
||||
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
||||
a stage dependency. BuildKit runs stages in parallel by default; without
|
||||
this line, the build stage would not wait for lint to finish and a lint
|
||||
failure might not fail the overall build.
|
||||
- If the project uses `//go:embed` directives that reference build artifacts
|
||||
(e.g. a web frontend compiled in a separate stage), the lint stage must
|
||||
create placeholder files so the embed directives resolve. Example:
|
||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||
The lint stage should not depend on the actual build output — it exists to
|
||||
fail fast.
|
||||
- If the project requires CGO or system libraries for linting (e.g.
|
||||
`vips-dev`), install them in the lint stage with `apk add`.
|
||||
- The build stage runs `make test` after compilation setup. Tests run in the
|
||||
build stage, not the lint stage, because they may require compiled
|
||||
artifacts or heavier dependencies.
|
||||
|
||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
||||
Dockerfile already runs `make check`, a successful build implies all checks
|
||||
pass.
|
||||
|
||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
||||
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||
|
||||
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
|
||||
testing is not possible in the repo, `script/precommit` may skip `script/test`
|
||||
and run only `script/lint` and `script/fmt-check`. The hook is installed by
|
||||
`script/install-precommit`; the Makefile must provide a `make hooks` target
|
||||
that shims to it.
|
||||
|
||||
- All repos with software must have tests that run via the platform-standard
|
||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||
tests exist yet, add the most minimal test possible — e.g. importing the
|
||||
module under test to verify it compiles/parses. There is no excuse for
|
||||
`make test` to be a no-op.
|
||||
|
||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||
Makefile.
|
||||
|
||||
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
||||
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
||||
show full output. This keeps CI logs and `docker build` output clean on
|
||||
success (just package/suite summaries) while providing full diagnostic detail
|
||||
on failure (every test case, every assertion). The general shell pattern:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
@<test-command> || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
<test-command-with-v>; exit 1; }
|
||||
```
|
||||
|
||||
Go example:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
@go test -timeout 30s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -timeout 30s -race -v ./...; exit 1; }
|
||||
```
|
||||
|
||||
Python example:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
@python -m pytest || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
python -m pytest -v; exit 1; }
|
||||
```
|
||||
|
||||
The `exit 1` ensures the target always fails after a rerun — the first run
|
||||
already proved the tests are broken, so the build must not pass even if a
|
||||
flaky test happens to succeed on the second attempt. The rerun exists solely
|
||||
for diagnostic output.
|
||||
|
||||
- Docker builds must complete in under 5 minutes.
|
||||
|
||||
- `make check` must not modify any files in the repo. Tests may use temporary
|
||||
directories.
|
||||
|
||||
- `main` must always pass `make check`, no exceptions.
|
||||
|
||||
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
||||
must be in `.gitignore`. No exceptions.
|
||||
|
||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||
Fetch the standard `.gitignore` from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||
a new repo.
|
||||
|
||||
- **No build artifacts in version control.** Code-derived data (compiled
|
||||
bundles, minified output, generated assets) must never be committed to the
|
||||
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
||||
should generate these at build time. Notable exception: Go protobuf generated
|
||||
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
||||
downloads code but does not execute code generation.
|
||||
|
||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||
|
||||
- Never force-push to `main`.
|
||||
|
||||
- Make all changes on a feature branch. You can do whatever you want on a
|
||||
feature branch.
|
||||
|
||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||
manually by the user. Fetch from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
||||
|
||||
- When pinning images or packages by hash, add a comment above the reference
|
||||
with the version and date (YYYY-MM-DD).
|
||||
|
||||
- Use `yarn`, not `npm`.
|
||||
|
||||
- Write all dates as YYYY-MM-DD (ISO 8601).
|
||||
|
||||
- Simple projects should be configured with environment variables.
|
||||
|
||||
- Dockerized web services listen on port 8080 by default, overridable with
|
||||
`PORT`.
|
||||
|
||||
- **HTTP/web services must be hardened for production internet exposure before
|
||||
tagging 1.0.** This means full compliance with security best practices
|
||||
including, without limitation, all of the following:
|
||||
- **Security headers** on every response:
|
||||
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
|
||||
and `includeSubDomains`.
|
||||
- `Content-Security-Policy` (CSP) with a restrictive default policy
|
||||
(`default-src 'self'` as a baseline, tightened per-resource as
|
||||
needed). Never use `unsafe-inline` or `unsafe-eval` unless
|
||||
unavoidable, and document the reason.
|
||||
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
|
||||
Prefer the `frame-ancestors` CSP directive as the primary control.
|
||||
- `X-Content-Type-Options: nosniff`.
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
|
||||
- `Permissions-Policy` restricting access to browser features the
|
||||
application does not use (camera, microphone, geolocation, etc.).
|
||||
- **Request and response limits:**
|
||||
- Maximum request body size enforced on all endpoints (e.g. Go
|
||||
`http.MaxBytesReader`). Choose a sane default per-route; never accept
|
||||
unbounded input.
|
||||
- Maximum response body size where applicable (e.g. paginated APIs).
|
||||
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
|
||||
against slowloris attacks.
|
||||
- `WriteTimeout` on the `http.Server`.
|
||||
- `IdleTimeout` on the `http.Server`.
|
||||
- Per-handler execution time limits via `context.WithTimeout` or
|
||||
chi/stdlib `middleware.Timeout`.
|
||||
- **Authentication and session security:**
|
||||
- Rate limiting on password-based authentication endpoints. API keys are
|
||||
high-entropy and not susceptible to brute force, so they are exempt.
|
||||
- CSRF tokens on all state-mutating HTML forms. API endpoints
|
||||
authenticated via `Authorization` header (Bearer token, API key) are
|
||||
exempt because the browser does not attach these automatically.
|
||||
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
|
||||
MD5, or SHA.
|
||||
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
|
||||
`Strict`) attributes.
|
||||
- **Reverse proxy awareness:**
|
||||
- True client IP detection when behind a reverse proxy
|
||||
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
||||
forwarded headers only from a configured set of trusted proxy
|
||||
addresses — never trust `X-Forwarded-For` unconditionally.
|
||||
- **CORS:**
|
||||
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
|
||||
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
|
||||
only for public, unauthenticated read-only APIs.
|
||||
- **Error handling:**
|
||||
- Internal errors must never leak stack traces, SQL queries, file paths,
|
||||
or other implementation details to the client. Return generic error
|
||||
messages in production; detailed errors only when `DEBUG` is enabled.
|
||||
- **TLS:**
|
||||
- Services never terminate TLS directly. They are always deployed behind
|
||||
a TLS-terminating reverse proxy. The service itself listens on plain
|
||||
HTTP. However, HSTS headers and `Secure` cookie flags must still be
|
||||
set by the application so that the browser enforces HTTPS end-to-end.
|
||||
|
||||
This list is non-exhaustive. Apply defense-in-depth: if a standard security
|
||||
hardening measure exists for HTTP services and is not listed here, it is
|
||||
still expected. When in doubt, harden.
|
||||
|
||||
- `README.md` is the primary documentation. Required sections:
|
||||
- **Description**: First line must include the project name, purpose,
|
||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||
- **Entrypoints**: Opens by stating that the repo adheres to the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
standard (with that link), then documents each provided `script/`
|
||||
entrypoint and its purpose.
|
||||
- **Rationale**: Why does this exist?
|
||||
- **Design**: How is the program structured?
|
||||
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||
the todo list in the README so a new agent can pick up where the last one
|
||||
left off.
|
||||
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
||||
`LICENSE` file in the repo root and a License section in the README.
|
||||
- **Author**: [@sneak](https://sneak.berlin).
|
||||
|
||||
- First commit of a new repo should contain only `README.md`.
|
||||
|
||||
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
|
||||
committing.
|
||||
|
||||
- Use SemVer.
|
||||
|
||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||
the binary.
|
||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||
tracking table itself. Nothing else.
|
||||
- `001_schema.sql` — the full application schema.
|
||||
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||
Never edit existing migrations after release.
|
||||
|
||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||
settings.
|
||||
|
||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||
subdirectory names:
|
||||
- `bin/` — executable scripts and tools
|
||||
- `cmd/` — Go command entrypoints
|
||||
- `configs/` — configuration templates and examples
|
||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||
- `docs/` — documentation and markdown (README.md stays in root)
|
||||
- `internal/` — Go internal packages
|
||||
- `internal/db/migrations/` — database migrations
|
||||
- `pkg/` — Go library packages
|
||||
- `share/` — systemd units, data files
|
||||
- `static/` — static assets (images, fonts, etc.)
|
||||
- `web/` — web frontend source
|
||||
|
||||
- When setting up a new repo, files from the `prompts` repo may be used as
|
||||
templates. Fetch them from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
||||
|
||||
- New repos must contain at minimum:
|
||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||
- `Makefile`
|
||||
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
|
||||
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
|
||||
`install-precommit`)
|
||||
- `Dockerfile`, `.dockerignore`
|
||||
- `.gitea/workflows/check.yml`
|
||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||
- Python: `pyproject.toml`
|
||||
@@ -1,95 +0,0 @@
|
||||
# Workflow
|
||||
|
||||
- branch (from `main`)
|
||||
- do the work in Next Step
|
||||
- move Next Step to the top of Completed Steps
|
||||
- move the top item of Future Steps into Next Step
|
||||
- commit (`TODO.md` changes in the same commit as the work)
|
||||
- merge to `main` if the branch is not protected, otherwise open a PR
|
||||
- push
|
||||
|
||||
# Status
|
||||
|
||||
1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. `make check` is green
|
||||
as of the golangci-lint v2.12.2 update.
|
||||
|
||||
# Next Step
|
||||
|
||||
Confirm `.gitea/workflows/check.yml` gates merges on `make check` so main cannot
|
||||
regress.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-09-23: Apps are now built with BuildKit, so the stages of a multi-stage
|
||||
build stay in Docker's size-limited build cache instead of piling up as
|
||||
untagged images; build progress is still written to the deployment log as
|
||||
plain text (#220).
|
||||
|
||||
- 2026-09-23: After a successful deploy, upaas removes the app's images other
|
||||
than the running one and the one rollback would use, together with the
|
||||
untagged images they were built on (#216).
|
||||
- 2026-09-23: The git clone container is now removed together with its anonymous
|
||||
volume (the `alpine/git` image declares one at `/git`), so a deploy no longer
|
||||
leaves a Docker volume behind (#215).
|
||||
- 2026-09-23: Deployment log files are now stored under `logs/<appname>/`
|
||||
instead of `logs/<hostname>/<appname>/`, so downloads keep working after the
|
||||
upaas container is recreated; logs written under an old hostname directory are
|
||||
still found (#214).
|
||||
- 2026-09-23: Fixed the flaky `t.TempDir` cleanup race in `internal/handlers`
|
||||
(the one fixed in `internal/service/webhook` by #198):
|
||||
`TestHandleWebhookProcessesValidWebhook` now waits with the webhook service's
|
||||
`WaitForDeployments` instead of sleeping (#211).
|
||||
- 2026-09-22: Vendored the canonical prettier/format toolchain from the
|
||||
`sneak/prompts` scaffold: added `.prettierrc` (tabWidth 4, proseWrap always),
|
||||
pinned `package.json` + `yarn.lock` (prettier 3.8.1), taught
|
||||
`script/bootstrap` to install a pinned node/yarn via a hash-verified nvm
|
||||
archive, and switched `script/fmt` to the pinned prettier reading
|
||||
`.prettierrc` (no inline flags) over `static/js/*.js` and `**/*.md`. Reflowed
|
||||
all markdown to house style; `alpine.min.js` stays byte-identical (#203).
|
||||
- 2026-09-22: Fixed the flaky `t.TempDir` cleanup race in
|
||||
`internal/service/webhook` by tracking the async deployment goroutine in a
|
||||
`sync.WaitGroup` and exposing `WaitForDeployments`; tests now synchronize on
|
||||
completion instead of sleeping (#198).
|
||||
- 2026-09-22: Linting now runs only in Docker. Added `Dockerfile.lint` (pinned
|
||||
golangci-lint v2.12.2, cache-busted via a `GATE_RUN` build arg so the linter
|
||||
always executes), reduced `script/lint` to building it, dropped the
|
||||
golangci-lint install from `script/bootstrap`, and switched the `Dockerfile`
|
||||
lint stage to invoke `golangci-lint` directly instead of `make lint` to avoid
|
||||
docker-in-docker (#188).
|
||||
- 2026-09-22: Added `.prettierignore` so `make fmt` no longer rewrites the
|
||||
vendored `static/js/alpine.min.js` bundle (#185).
|
||||
- 2026-09-22: Fixed the gosec G703 path-traversal finding in the deploy log
|
||||
download handler by verifying the resolved path stays within the deploy log
|
||||
directory before serving, returning 404 on escape (#177).
|
||||
- 2026-09-22: `script/bootstrap` now installs a pinned `goimports`
|
||||
(`golang.org/x/tools` v0.49.0) into `/usr/local/bin`, so `make fmt` succeeds
|
||||
on a fresh machine after `make bootstrap` (#184).
|
||||
- 2026-09-09: Fixed four deployability blockers found by QA: CSRF origin check
|
||||
over plain HTTP (`UPAAS_PLAINTEXT_HTTP`, #189), pulling the git image when
|
||||
absent (#190), the env-var editor CSRF token lookup (#191), and the
|
||||
port-mapping delete form's CSRF field (#192).
|
||||
- 2026-08-07: Updated golangci-lint to v2.12.2 (canonical `.golangci.yml`,
|
||||
`Dockerfile` lint stage pin, `script/bootstrap` release-archive pins) and
|
||||
fixed all resulting lint findings (noctx, gosec, goconst, lll, dupl,
|
||||
nolintlint); `make check` green.
|
||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
||||
shims, README Entrypoints section
|
||||
- 2026-03-11: Monolithic env var editing with bulk save (#158).
|
||||
- 2026-03-10: Webhook event history UI page (#164); added missing Makefile
|
||||
docker and hooks targets plus test timeout (#159); notification settings
|
||||
passed from create form (#160).
|
||||
- 2026-03-03: REPO_POLICIES compliance file set added (#155).
|
||||
- 2026-03-01: Module path changed to sneak.berlin/go/upaas (#143); Dockerfile
|
||||
split into lint and build stages with forced lint execution (#152, #154).
|
||||
- 2026-02-26: 1.0.0 tagged; dashboard CSRFField crash fixed (#146).
|
||||
- 1.0 audit bug fixes (#120-#125): deferred rollback on commit error, deployment
|
||||
log size cap, error path rendering, docker-compose bind mount, domain type
|
||||
refactor.
|
||||
- CI simplified to docker build only (#130).
|
||||
- 2025-12-29 onward: core PaaS built out: deploys with real-time build log
|
||||
streaming, container start/stop/restart and logs, TCP/UDP port mapping,
|
||||
Alpine.js UI, Slack notifications, ULID app IDs, session handling.
|
||||
|
||||
# Future Steps
|
||||
|
||||
- Resume feature work only after main is green.
|
||||
+14
-14
@@ -4,20 +4,20 @@ package main
|
||||
import (
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"sneak.berlin/go/upaas/internal/docker"
|
||||
"sneak.berlin/go/upaas/internal/globals"
|
||||
"sneak.berlin/go/upaas/internal/handlers"
|
||||
"sneak.berlin/go/upaas/internal/healthcheck"
|
||||
"sneak.berlin/go/upaas/internal/logger"
|
||||
"sneak.berlin/go/upaas/internal/middleware"
|
||||
"sneak.berlin/go/upaas/internal/server"
|
||||
"sneak.berlin/go/upaas/internal/service/app"
|
||||
"sneak.berlin/go/upaas/internal/service/auth"
|
||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
||||
"sneak.berlin/go/upaas/internal/service/notify"
|
||||
"sneak.berlin/go/upaas/internal/service/webhook"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/docker"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/handlers"
|
||||
"git.eeqj.de/sneak/upaas/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/middleware"
|
||||
"git.eeqj.de/sneak/upaas/internal/server"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/app"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/notify"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/webhook"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
upaas:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- upaas-data:/var/lib/upaas
|
||||
# environment:
|
||||
# Optional: uncomment to enable debug logging
|
||||
# - DEBUG=true
|
||||
# Optional: Sentry error reporting
|
||||
# - SENTRY_DSN=https://...
|
||||
# Optional: Prometheus metrics auth
|
||||
# - METRICS_USERNAME=prometheus
|
||||
# - METRICS_PASSWORD=secret
|
||||
|
||||
volumes:
|
||||
upaas-data:
|
||||
@@ -1,4 +1,4 @@
|
||||
module sneak.berlin/go/upaas
|
||||
module git.eeqj.de/sneak/upaas
|
||||
|
||||
go 1.25
|
||||
|
||||
@@ -13,31 +13,20 @@ require (
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/moby/buildkit v0.16.0
|
||||
github.com/oklog/ulid/v2 v2.1.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.uber.org/fx v1.24.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/time v0.12.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/console v1.0.4 // indirect
|
||||
github.com/containerd/containerd v1.7.21 // indirect
|
||||
github.com/containerd/containerd/api v1.7.19 // indirect
|
||||
github.com/containerd/continuity v0.4.3 // indirect
|
||||
github.com/containerd/errdefs v0.1.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/containerd/ttrpc v1.2.5 // indirect
|
||||
github.com/containerd/typeurl/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
@@ -46,23 +35,12 @@ require (
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gofrs/flock v0.12.1 // indirect
|
||||
github.com/gogo/googleapis v1.4.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/in-toto/in-toto-golang v0.5.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/locker v1.0.1 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/signal v0.7.1 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
@@ -77,42 +55,26 @@ require (
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
|
||||
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tonistiigi/fsutil v0.0.0-20240424095704-91a3fc46842c // indirect
|
||||
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 // indirect
|
||||
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
|
||||
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.uber.org/dig v1.19.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc v1.77.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
|
||||
@@ -1,60 +1,19 @@
|
||||
cloud.google.com/go v0.112.0 h1:tpFCD7hpHFlQ8yPwT3x+QeXqc2T6+n6T+hmABHfDUSM=
|
||||
cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
|
||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 h1:59MxjQVfjXsBpLy+dbd2/ELV5ofnUkUZBvWSC85sheA=
|
||||
github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0/go.mod h1:OahwfttHWG6eJ0clwcfBAHoDI6X/LV/15hx/wlMZSrU=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/Microsoft/hcsshim v0.11.7 h1:vl/nj3Bar/CvJSYo7gIQPyRWc9f3c6IeSNavBTSZNZQ=
|
||||
github.com/Microsoft/hcsshim v0.11.7/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA09d4bExKcU=
|
||||
github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 h1:aM1rlcoLz8y5B2r4tTLMiVTrMtpfY0O8EScKJxaSaEc=
|
||||
github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0=
|
||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
|
||||
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
|
||||
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
|
||||
github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM=
|
||||
github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw=
|
||||
github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
|
||||
github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
||||
github.com/containerd/containerd v1.7.21 h1:USGXRK1eOC/SX0L195YgxTHb0a00anxajOzgfN0qrCA=
|
||||
github.com/containerd/containerd v1.7.21/go.mod h1:e3Jz1rYRUZ2Lt51YrH9Rz0zPyJBOlSvB3ghr2jbVD8g=
|
||||
github.com/containerd/containerd/api v1.7.19 h1:VWbJL+8Ap4Ju2mx9c9qS1uFSB1OVYr5JJrW2yT5vFoA=
|
||||
github.com/containerd/containerd/api v1.7.19/go.mod h1:fwGavl3LNwAV5ilJ0sbrABL44AQxmNjDRcwheXDb6Ig=
|
||||
github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8=
|
||||
github.com/containerd/continuity v0.4.3/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ=
|
||||
github.com/containerd/errdefs v0.1.0 h1:m0wCRBiu1WJT/Fr+iOoQHMQS/eP5myQ8lCv4Dz5ZURM=
|
||||
github.com/containerd/errdefs v0.1.0/go.mod h1:YgWiiHtLmSeBrvpw+UfPijzbLaB77mEG1WwJTDETIV0=
|
||||
github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY=
|
||||
github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/nydus-snapshotter v0.14.0 h1:6/eAi6d7MjaeLLuMO8Udfe5GVsDudmrDNO4SGETMBco=
|
||||
github.com/containerd/nydus-snapshotter v0.14.0/go.mod h1:TT4jv2SnIDxEBu4H2YOvWQHPOap031ydTaHTuvc5VQk=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/containerd/stargz-snapshotter v0.15.1 h1:fpsP4kf/Z4n2EYnU0WT8ZCE3eiKDwikDhL6VwxIlgeA=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.15.1 h1:eXJjw9RbkLFgioVaTG+G/ZW/0kEe2oEKCdS/ZxIyoCU=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.15.1/go.mod h1:gr2RNwukQ/S9Nv33Lt6UC7xEx58C+LHRdoqbEKjz1Kk=
|
||||
github.com/containerd/ttrpc v1.2.5 h1:IFckT1EFQoFBMG4c3sMdT8EP3/aKfumK1msY+Ze4oLU=
|
||||
github.com/containerd/ttrpc v1.2.5/go.mod h1:YCXHsb32f+Sq5/72xHubdiJRQY9inL4a4ZQrAbN1q9o=
|
||||
github.com/containerd/typeurl/v2 v2.2.0 h1:6NBDbQzr7I5LHgp34xAXYF5DOTQDn05X58lsPEmzLso=
|
||||
github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -64,12 +23,8 @@ github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFT
|
||||
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8=
|
||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
@@ -87,22 +42,12 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0=
|
||||
github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
|
||||
@@ -113,13 +58,6 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/in-toto/in-toto-golang v0.5.0 h1:hb8bgwr0M2hGdDsLjkJ3ZqJ8JFLL/tgYdAxF/XEFBbY=
|
||||
github.com/in-toto/in-toto-golang v0.5.0/go.mod h1:/Rq0IZHLV7Ku5gielPT4wPHJfH1GdHMCq8+WPxw8/BE=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
@@ -134,20 +72,12 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/moby/buildkit v0.16.0 h1:wOVBj1o5YNVad/txPQNXUXdelm7Hs/i0PUFjzbK0VKE=
|
||||
github.com/moby/buildkit v0.16.0/go.mod h1:Xqx/5GlrqE1yIRORk0NSCVDFpQAU1WjlT6KHYZdisIQ=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
|
||||
github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
|
||||
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/signal v0.7.1 h1:PrQxdvxcGijdo6UXXo/lU/TvHUWyPhj7UOpSo8tuvk0=
|
||||
github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
@@ -164,13 +94,7 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/opencontainers/runtime-spec v1.2.0 h1:z97+pHb3uELt/yiAWD691HNHQIF07bE7dzrbT927iTk=
|
||||
github.com/opencontainers/runtime-spec v1.2.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
|
||||
github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaLpt7tQ7oU=
|
||||
github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -190,16 +114,10 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.4.0 h1:b23VGrQhTA8cN2CbBw7/FulN9fTtqYUdS5+Oxzt+DUE=
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.4.0/go.mod h1:FGBZgq2tXWICsxWQW1msNf49F0Pf2Op5Htayx335Qbs=
|
||||
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
|
||||
github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spdx/tools-golang v0.5.3 h1:ialnHeEYUC4+hkm5vJm4qz2x+oEJbS0mAMFrNXdQraY=
|
||||
github.com/spdx/tools-golang v0.5.3/go.mod h1:/ETOahiAo96Ob0/RAIBmFZw6XN0yTnyr/uFZm2NTMhI=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
@@ -209,32 +127,15 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tonistiigi/fsutil v0.0.0-20240424095704-91a3fc46842c h1:+6wg/4ORAbnSoGDzg2Q1i3CeMcT/jjhye/ZfnBHy7/M=
|
||||
github.com/tonistiigi/fsutil v0.0.0-20240424095704-91a3fc46842c/go.mod h1:vbbYqJlnswsbJqWUcJN8fKtBhnEgldDrcagTgnBVKKM=
|
||||
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 h1:7I5c2Ig/5FgqkYOh/N87NzoyI9U15qUPXhDD8uCupv8=
|
||||
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4/go.mod h1:278M4p8WsNh3n4a1eqiFcV2FGk7wE5fwUpUom9mK9lE=
|
||||
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea h1:SXhTLE6pb6eld/v/cCndK0AMpt1wiVFb/YYmqB3/QG0=
|
||||
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea/go.mod h1:WPnis/6cRcDZSUvVmezrxJPkiO87ThFYsoUiMwWNDJk=
|
||||
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab h1:H6aJ0yKQ0gF49Qb2z5hI1UHxSQt4JMyxebFR15KnApw=
|
||||
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab/go.mod h1:ulncasL3N9uLrVann0m+CDlJKWsIAP34MPcOJF6VRvc=
|
||||
github.com/vbatts/tar-split v0.11.5 h1:3bHCTIheBm1qFTcgh9oPu+nNBtX+XJIupG/vacinCts=
|
||||
github.com/vbatts/tar-split v0.11.5/go.mod h1:yZbwRsSeGjusneWgA781EKej9HF8vme8okylkAeNKLk=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 h1:SpGay3w+nEwMpfVnbqOLH5gY52/foP8RE8UzTZ1pdSE=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1/go.mod h1:4UoMYEZOC0yN/sPGH76KPkkU7zgiEWYWL9vwmbnTJPE=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 h1:gbhw/u49SS3gkPWiYweQNJGm/uJN5GkI/FrosxSHT7A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1/go.mod h1:GnOaBaFQ2we3b9AGWJpsBa7v1S5RlQzlC3O7dRMxZhM=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
@@ -274,27 +175,19 @@ golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
@@ -313,10 +206,6 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 h1:KAeGQVN3M9nD0/bQXnr/ClcEMJ968gUXJQ9pwfSynuQ=
|
||||
google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80/go.mod h1:cc8bqMqtv9gMOr0zHg2Vzff5ULhhL2IXP4sbcn32Dro=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/globals"
|
||||
"sneak.berlin/go/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
)
|
||||
|
||||
// defaultPort is the default HTTP server port.
|
||||
@@ -45,14 +45,13 @@ type Config struct {
|
||||
Port int
|
||||
Debug bool
|
||||
DataDir string
|
||||
HostDataDir string // Host path for DataDir (Docker bind mounts in container)
|
||||
HostDataDir string // Host path for DataDir (for Docker bind mounts when running in container)
|
||||
DockerHost string
|
||||
SentryDSN string
|
||||
MaintenanceMode bool
|
||||
PlaintextHTTP bool // clients reach µPaaS over plain HTTP (no TLS-terminating proxy)
|
||||
MetricsUsername string
|
||||
MetricsPassword string
|
||||
SessionSecret string `json:"-"`
|
||||
SessionSecret string
|
||||
CORSOrigins string
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
@@ -101,7 +100,6 @@ func setupViper(name string) {
|
||||
viper.SetDefault("DOCKER_HOST", "unix:///var/run/docker.sock")
|
||||
viper.SetDefault("SENTRY_DSN", "")
|
||||
viper.SetDefault("MAINTENANCE_MODE", false)
|
||||
viper.SetDefault("PLAINTEXT_HTTP", false)
|
||||
viper.SetDefault("METRICS_USERNAME", "")
|
||||
viper.SetDefault("METRICS_PASSWORD", "")
|
||||
viper.SetDefault("SESSION_SECRET", "")
|
||||
@@ -137,7 +135,6 @@ func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
|
||||
DockerHost: viper.GetString("DOCKER_HOST"),
|
||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||
PlaintextHTTP: viper.GetBool("PLAINTEXT_HTTP"),
|
||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||
SessionSecret: viper.GetString("SESSION_SECRET"),
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
"sneak.berlin/go/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
)
|
||||
|
||||
// dataDirPermissions is the file permission for the data directory.
|
||||
@@ -178,8 +178,7 @@ func HashWebhookSecret(secret string) string {
|
||||
|
||||
func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error {
|
||||
rows, err := d.database.QueryContext(ctx,
|
||||
"SELECT id, webhook_secret FROM apps"+
|
||||
" WHERE webhook_secret_hash = '' AND webhook_secret != ''")
|
||||
"SELECT id, webhook_secret FROM apps WHERE webhook_secret_hash = '' AND webhook_secret != ''")
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying apps for backfill: %w", err)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
)
|
||||
|
||||
func TestHashWebhookSecret(t *testing.T) {
|
||||
|
||||
+50
-159
@@ -2,80 +2,36 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// bootstrapVersion is the migration that creates the schema_migrations
|
||||
// table itself. It is applied before the normal migration loop.
|
||||
const bootstrapVersion = 0
|
||||
|
||||
// ErrInvalidMigrationFilename indicates a migration filename does not follow
|
||||
// the expected "<version>.sql" or "<version>_<description>.sql" pattern.
|
||||
var ErrInvalidMigrationFilename = errors.New("invalid migration filename")
|
||||
|
||||
// ParseMigrationVersion extracts the numeric version prefix from a migration
|
||||
// filename. Filenames must follow the pattern "<version>.sql" or
|
||||
// "<version>_<description>.sql", where version is a zero-padded numeric
|
||||
// string (e.g. "001", "002"). Returns the version as an integer and an
|
||||
// error if the filename does not match the expected pattern.
|
||||
func ParseMigrationVersion(filename string) (int, error) {
|
||||
name := strings.TrimSuffix(filename, ".sql")
|
||||
if name == "" || name == filename {
|
||||
return 0, fmt.Errorf(
|
||||
"%w: %q has no .sql extension or is empty",
|
||||
ErrInvalidMigrationFilename, filename,
|
||||
func (d *Database) migrate(ctx context.Context) error {
|
||||
// Create migrations table if not exists
|
||||
_, err := d.database.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
}
|
||||
|
||||
// Split on underscore to separate version from description.
|
||||
// If there's no underscore, the entire stem is the version.
|
||||
versionStr, _, _ := strings.Cut(name, "_")
|
||||
|
||||
if versionStr == "" {
|
||||
return 0, fmt.Errorf(
|
||||
"%w: %q has empty version prefix",
|
||||
ErrInvalidMigrationFilename, filename,
|
||||
)
|
||||
}
|
||||
|
||||
// Validate the version is purely numeric.
|
||||
for _, ch := range versionStr {
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0, fmt.Errorf(
|
||||
"%w: %q version %q contains non-numeric character %q",
|
||||
ErrInvalidMigrationFilename, filename, versionStr, string(ch),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
version, err := strconv.Atoi(versionStr)
|
||||
`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%w: %q: %w", ErrInvalidMigrationFilename, filename, err)
|
||||
return fmt.Errorf("failed to create migrations table: %w", err)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// collectMigrations reads the embedded migrations directory and returns
|
||||
// migration filenames sorted lexicographically.
|
||||
func collectMigrations() ([]string, error) {
|
||||
// Get list of migration files
|
||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read migrations directory: %w", err)
|
||||
return fmt.Errorf("failed to read migrations directory: %w", err)
|
||||
}
|
||||
|
||||
var migrations []string
|
||||
// Sort migrations by name
|
||||
migrations := make([]string, 0, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
||||
@@ -85,118 +41,54 @@ func collectMigrations() ([]string, error) {
|
||||
|
||||
sort.Strings(migrations)
|
||||
|
||||
return migrations, nil
|
||||
}
|
||||
|
||||
// bootstrapMigrationsTable ensures the schema_migrations table exists by
|
||||
// applying 000_migration.sql if the table is missing.
|
||||
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||
var tableExists int
|
||||
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||
).Scan(&tableExists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for migrations table: %w", err)
|
||||
}
|
||||
|
||||
if tableExists == 0 {
|
||||
return applyBootstrapMigration(ctx, db, log)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyBootstrapMigration reads and executes 000_migration.sql to create the
|
||||
// schema_migrations table on a fresh database.
|
||||
func applyBootstrapMigration(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||
content, err := migrationsFS.ReadFile("migrations/000_migration.sql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read bootstrap migration 000_migration.sql: %w", err)
|
||||
}
|
||||
|
||||
if log != nil {
|
||||
log.Info("applying bootstrap migration", "version", bootstrapVersion)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx, string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyMigrations applies all pending migrations to db. An optional logger
|
||||
// may be provided for informational output; pass nil for silent operation.
|
||||
// This is exported so tests can apply the real schema without the full fx
|
||||
// lifecycle.
|
||||
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||
bootstrapErr := bootstrapMigrationsTable(ctx, db, log)
|
||||
if bootstrapErr != nil {
|
||||
return bootstrapErr
|
||||
}
|
||||
|
||||
migrations, err := collectMigrations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply each migration
|
||||
for _, migration := range migrations {
|
||||
version, parseErr := ParseMigrationVersion(migration)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
|
||||
// Check if already applied.
|
||||
var count int
|
||||
|
||||
err = db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
||||
version,
|
||||
).Scan(&count)
|
||||
applied, err := d.isMigrationApplied(ctx, migration)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check migration %s: %w", migration, err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
if log != nil {
|
||||
log.Debug("migration already applied", "version", version)
|
||||
}
|
||||
if applied {
|
||||
d.log.Debug("migration already applied", "migration", migration)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply migration in a transaction.
|
||||
applyErr := applyMigrationTx(ctx, db, migration, version)
|
||||
if applyErr != nil {
|
||||
return applyErr
|
||||
err = d.applyMigration(ctx, migration)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
|
||||
}
|
||||
|
||||
if log != nil {
|
||||
log.Info("migration applied", "version", version)
|
||||
}
|
||||
d.log.Info("migration applied", "migration", migration)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyMigrationTx reads and executes a migration file within a transaction,
|
||||
// recording the version in schema_migrations on success.
|
||||
func applyMigrationTx(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
filename string,
|
||||
version int,
|
||||
) error {
|
||||
content, err := migrationsFS.ReadFile("migrations/" + filename)
|
||||
func (d *Database) isMigrationApplied(ctx context.Context, version string) (bool, error) {
|
||||
var count int
|
||||
|
||||
err := d.database.QueryRowContext(
|
||||
ctx,
|
||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
||||
version,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration %s: %w", filename, err)
|
||||
return false, fmt.Errorf("failed to query migration status: %w", err)
|
||||
}
|
||||
|
||||
transaction, err := db.BeginTx(ctx, nil)
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (d *Database) applyMigration(ctx context.Context, filename string) error {
|
||||
content, err := migrationsFS.ReadFile("migrations/" + filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction for migration %s: %w", filename, err)
|
||||
return fmt.Errorf("failed to read migration file: %w", err)
|
||||
}
|
||||
|
||||
transaction, err := d.database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
@@ -205,27 +97,26 @@ func applyMigrationTx(
|
||||
}
|
||||
}()
|
||||
|
||||
// Execute migration
|
||||
_, err = transaction.ExecContext(ctx, string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute migration %s: %w", filename, err)
|
||||
return fmt.Errorf("failed to execute migration: %w", err)
|
||||
}
|
||||
|
||||
_, err = transaction.ExecContext(ctx,
|
||||
// Record migration
|
||||
_, err = transaction.ExecContext(
|
||||
ctx,
|
||||
"INSERT INTO schema_migrations (version) VALUES (?)",
|
||||
version,
|
||||
filename,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record migration %s: %w", filename, err)
|
||||
return fmt.Errorf("failed to record migration: %w", err)
|
||||
}
|
||||
|
||||
err = transaction.Commit()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to commit migration %s: %w", filename, err)
|
||||
commitErr := transaction.Commit()
|
||||
if commitErr != nil {
|
||||
return fmt.Errorf("failed to commit migration: %w", commitErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) migrate(ctx context.Context) error {
|
||||
return ApplyMigrations(ctx, d.database, d.log)
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
-- Migration 000: Schema migrations tracking table
|
||||
-- Applied as a bootstrap step before the normal migration loop.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Add CPU and memory resource limits per app
|
||||
ALTER TABLE apps ADD COLUMN cpu_limit REAL;
|
||||
ALTER TABLE apps ADD COLUMN memory_limit INTEGER;
|
||||
@@ -1,117 +0,0 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
)
|
||||
|
||||
func TestParseMigrationVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
filename string
|
||||
wantVersion int
|
||||
wantErr bool
|
||||
}{
|
||||
{filename: "000_migration.sql", wantVersion: 0},
|
||||
{filename: "001_initial.sql", wantVersion: 1},
|
||||
{filename: "002_remove_container_id.sql", wantVersion: 2},
|
||||
{filename: "007_add_resource_limits.sql", wantVersion: 7},
|
||||
{filename: "100_large_version.sql", wantVersion: 100},
|
||||
{filename: ".sql", wantErr: true},
|
||||
{filename: "_foo.sql", wantErr: true},
|
||||
{filename: "abc_foo.sql", wantErr: true},
|
||||
{filename: "1a2_bad.sql", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filename, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
version, err := database.ParseMigrationVersion(tt.filename)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantVersion, version)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMigrationsFreshDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
err = database.ApplyMigrations(ctx, db, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify schema_migrations table exists with INTEGER version column.
|
||||
var version int
|
||||
|
||||
err = db.QueryRowContext(ctx,
|
||||
"SELECT version FROM schema_migrations WHERE version = 0",
|
||||
).Scan(&version)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, version)
|
||||
|
||||
// Verify that all migrations were recorded.
|
||||
var count int
|
||||
|
||||
err = db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM schema_migrations",
|
||||
).Scan(&count)
|
||||
require.NoError(t, err)
|
||||
// 000 bootstrap + 001 through 007 = 8 entries.
|
||||
assert.Equal(t, 8, count)
|
||||
|
||||
// Verify application tables were created by the migrations.
|
||||
var tableCount int
|
||||
|
||||
err = db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'",
|
||||
).Scan(&tableCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, tableCount)
|
||||
}
|
||||
|
||||
func TestApplyMigrationsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Apply twice — second run should be a no-op.
|
||||
err = database.ApplyMigrations(ctx, db, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = database.ApplyMigrations(ctx, db, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
var count int
|
||||
|
||||
err = db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM schema_migrations",
|
||||
).Scan(&count)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 8, count)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
"sneak.berlin/go/upaas/internal/logger"
|
||||
)
|
||||
|
||||
// NewTestDatabase creates an in-memory Database for testing.
|
||||
// It runs migrations so all tables are available.
|
||||
func NewTestDatabase(t *testing.T) *Database {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := &config.Config{
|
||||
DataDir: tmpDir,
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
logWrapper := logger.NewForTest(log)
|
||||
|
||||
db, err := New(nil, Params{
|
||||
Logger: logWrapper,
|
||||
Config: cfg,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test database: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
if db.database != nil {
|
||||
_ = db.database.Close()
|
||||
}
|
||||
})
|
||||
|
||||
return db
|
||||
}
|
||||
+91
-290
@@ -4,7 +4,6 @@ package docker
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -15,7 +14,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
dockertypes "github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
@@ -23,16 +22,11 @@ import (
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/archive"
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
"github.com/docker/go-connections/nat"
|
||||
controlapi "github.com/moby/buildkit/api/services/control"
|
||||
buildkitclient "github.com/moby/buildkit/client"
|
||||
"github.com/moby/buildkit/util/progress/progressui"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
)
|
||||
|
||||
// sshKeyPermissions is the file permission for SSH private keys.
|
||||
@@ -46,8 +40,7 @@ const stopTimeoutSeconds = 10
|
||||
|
||||
// gitImage is the Docker image used for git operations.
|
||||
// alpine/git v2.47.2 - pulled 2025-12-30
|
||||
const gitImage = "alpine/git@sha256:" +
|
||||
"d86f367afb53d022acc4377741e7334bc20add161bb10234272b91b459b4b7d8"
|
||||
const gitImage = "alpine/git@sha256:d86f367afb53d022acc4377741e7334bc20add161bb10234272b91b459b4b7d8"
|
||||
|
||||
// ErrNotConnected is returned when Docker client is not connected.
|
||||
var ErrNotConnected = errors.New("docker client not connected")
|
||||
@@ -123,7 +116,7 @@ type BuildImageOptions struct {
|
||||
func (c *Client) BuildImage(
|
||||
ctx context.Context,
|
||||
opts BuildImageOptions,
|
||||
) (ImageID, error) {
|
||||
) (string, error) {
|
||||
if c.docker == nil {
|
||||
return "", ErrNotConnected
|
||||
}
|
||||
@@ -144,15 +137,13 @@ func (c *Client) BuildImage(
|
||||
|
||||
// CreateContainerOptions contains options for creating a container.
|
||||
type CreateContainerOptions struct {
|
||||
Name string
|
||||
Image string
|
||||
Env map[string]string
|
||||
Labels map[string]string
|
||||
Volumes []VolumeMount
|
||||
Ports []PortMapping
|
||||
Network string
|
||||
CPULimit float64 // CPU cores (0.5 = half a core). 0 means unlimited.
|
||||
MemoryLimit int64 // Memory in bytes. 0 means unlimited.
|
||||
Name string
|
||||
Image string
|
||||
Env map[string]string
|
||||
Labels map[string]string
|
||||
Volumes []VolumeMount
|
||||
Ports []PortMapping
|
||||
Network string
|
||||
}
|
||||
|
||||
// VolumeMount represents a volume mount.
|
||||
@@ -169,14 +160,6 @@ type PortMapping struct {
|
||||
Protocol string // "tcp" or "udp"
|
||||
}
|
||||
|
||||
// nanoCPUsPerCPU is the number of NanoCPUs per CPU core.
|
||||
const nanoCPUsPerCPU = 1e9
|
||||
|
||||
// cpuLimitToNanoCPUs converts a CPU limit (e.g. 0.5 cores) to Docker NanoCPUs.
|
||||
func cpuLimitToNanoCPUs(cpuLimit float64) int64 {
|
||||
return int64(cpuLimit * nanoCPUsPerCPU)
|
||||
}
|
||||
|
||||
// buildPortConfig converts port mappings to Docker port configuration.
|
||||
func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
|
||||
exposedPorts := make(nat.PortSet)
|
||||
@@ -201,22 +184,28 @@ func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
|
||||
return exposedPorts, portBindings
|
||||
}
|
||||
|
||||
// buildEnvSlice converts an env map to a Docker-compatible env slice.
|
||||
func buildEnvSlice(env map[string]string) []string {
|
||||
envSlice := make([]string, 0, len(env))
|
||||
// CreateContainer creates a new container.
|
||||
func (c *Client) CreateContainer(
|
||||
ctx context.Context,
|
||||
opts CreateContainerOptions,
|
||||
) (string, error) {
|
||||
if c.docker == nil {
|
||||
return "", ErrNotConnected
|
||||
}
|
||||
|
||||
for key, val := range env {
|
||||
c.log.Info("creating container", "name", opts.Name, "image", opts.Image)
|
||||
|
||||
// Convert env map to slice
|
||||
envSlice := make([]string, 0, len(opts.Env))
|
||||
|
||||
for key, val := range opts.Env {
|
||||
envSlice = append(envSlice, key+"="+val)
|
||||
}
|
||||
|
||||
return envSlice
|
||||
}
|
||||
// Convert volumes to mounts
|
||||
mounts := make([]mount.Mount, 0, len(opts.Volumes))
|
||||
|
||||
// buildMounts converts volume mounts to Docker mount configuration.
|
||||
func buildMounts(volumes []VolumeMount) []mount.Mount {
|
||||
mounts := make([]mount.Mount, 0, len(volumes))
|
||||
|
||||
for _, vol := range volumes {
|
||||
for _, vol := range opts.Volumes {
|
||||
mounts = append(mounts, mount.Mount{
|
||||
Type: mount.TypeBind,
|
||||
Source: vol.HostPath,
|
||||
@@ -225,49 +214,21 @@ func buildMounts(volumes []VolumeMount) []mount.Mount {
|
||||
})
|
||||
}
|
||||
|
||||
return mounts
|
||||
}
|
||||
|
||||
// buildResources builds Docker resource constraints from container options.
|
||||
func buildResources(opts CreateContainerOptions) container.Resources {
|
||||
resources := container.Resources{}
|
||||
|
||||
if opts.CPULimit > 0 {
|
||||
resources.NanoCPUs = cpuLimitToNanoCPUs(opts.CPULimit)
|
||||
}
|
||||
|
||||
if opts.MemoryLimit > 0 {
|
||||
resources.Memory = opts.MemoryLimit
|
||||
}
|
||||
|
||||
return resources
|
||||
}
|
||||
|
||||
// CreateContainer creates a new container.
|
||||
func (c *Client) CreateContainer(
|
||||
ctx context.Context,
|
||||
opts CreateContainerOptions,
|
||||
) (ContainerID, error) {
|
||||
if c.docker == nil {
|
||||
return "", ErrNotConnected
|
||||
}
|
||||
|
||||
c.log.Info("creating container", "name", opts.Name, "image", opts.Image)
|
||||
|
||||
// Convert ports to exposed ports and port bindings
|
||||
exposedPorts, portBindings := buildPortConfig(opts.Ports)
|
||||
|
||||
// Create container
|
||||
resp, err := c.docker.ContainerCreate(ctx,
|
||||
&container.Config{
|
||||
Image: opts.Image,
|
||||
Env: buildEnvSlice(opts.Env),
|
||||
Env: envSlice,
|
||||
Labels: opts.Labels,
|
||||
ExposedPorts: exposedPorts,
|
||||
},
|
||||
&container.HostConfig{
|
||||
Mounts: buildMounts(opts.Volumes),
|
||||
Mounts: mounts,
|
||||
PortBindings: portBindings,
|
||||
NetworkMode: container.NetworkMode(opts.Network),
|
||||
Resources: buildResources(opts),
|
||||
RestartPolicy: container.RestartPolicy{
|
||||
Name: container.RestartPolicyUnlessStopped,
|
||||
},
|
||||
@@ -280,18 +241,18 @@ func (c *Client) CreateContainer(
|
||||
return "", fmt.Errorf("failed to create container: %w", err)
|
||||
}
|
||||
|
||||
return ContainerID(resp.ID), nil
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
// StartContainer starts a container.
|
||||
func (c *Client) StartContainer(ctx context.Context, containerID ContainerID) error {
|
||||
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
|
||||
if c.docker == nil {
|
||||
return ErrNotConnected
|
||||
}
|
||||
|
||||
c.log.Info("starting container", "id", containerID)
|
||||
|
||||
err := c.docker.ContainerStart(ctx, containerID.String(), container.StartOptions{})
|
||||
err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start container: %w", err)
|
||||
}
|
||||
@@ -300,7 +261,7 @@ func (c *Client) StartContainer(ctx context.Context, containerID ContainerID) er
|
||||
}
|
||||
|
||||
// StopContainer stops a container.
|
||||
func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) error {
|
||||
func (c *Client) StopContainer(ctx context.Context, containerID string) error {
|
||||
if c.docker == nil {
|
||||
return ErrNotConnected
|
||||
}
|
||||
@@ -309,11 +270,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) err
|
||||
|
||||
timeout := stopTimeoutSeconds
|
||||
|
||||
err := c.docker.ContainerStop(
|
||||
ctx,
|
||||
containerID.String(),
|
||||
container.StopOptions{Timeout: &timeout},
|
||||
)
|
||||
err := c.docker.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop container: %w", err)
|
||||
}
|
||||
@@ -324,7 +281,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) err
|
||||
// RemoveContainer removes a container.
|
||||
func (c *Client) RemoveContainer(
|
||||
ctx context.Context,
|
||||
containerID ContainerID,
|
||||
containerID string,
|
||||
force bool,
|
||||
) error {
|
||||
if c.docker == nil {
|
||||
@@ -333,11 +290,7 @@ func (c *Client) RemoveContainer(
|
||||
|
||||
c.log.Info("removing container", "id", containerID, "force", force)
|
||||
|
||||
err := c.docker.ContainerRemove(
|
||||
ctx,
|
||||
containerID.String(),
|
||||
container.RemoveOptions{Force: force},
|
||||
)
|
||||
err := c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove container: %w", err)
|
||||
}
|
||||
@@ -348,7 +301,7 @@ func (c *Client) RemoveContainer(
|
||||
// ContainerLogs returns the logs for a container.
|
||||
func (c *Client) ContainerLogs(
|
||||
ctx context.Context,
|
||||
containerID ContainerID,
|
||||
containerID string,
|
||||
tail string,
|
||||
) (string, error) {
|
||||
if c.docker == nil {
|
||||
@@ -361,7 +314,7 @@ func (c *Client) ContainerLogs(
|
||||
Tail: tail,
|
||||
}
|
||||
|
||||
reader, err := c.docker.ContainerLogs(ctx, containerID.String(), opts)
|
||||
reader, err := c.docker.ContainerLogs(ctx, containerID, opts)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get container logs: %w", err)
|
||||
}
|
||||
@@ -384,13 +337,13 @@ func (c *Client) ContainerLogs(
|
||||
// IsContainerRunning checks if a container is running.
|
||||
func (c *Client) IsContainerRunning(
|
||||
ctx context.Context,
|
||||
containerID ContainerID,
|
||||
containerID string,
|
||||
) (bool, error) {
|
||||
if c.docker == nil {
|
||||
return false, ErrNotConnected
|
||||
}
|
||||
|
||||
inspect, err := c.docker.ContainerInspect(ctx, containerID.String())
|
||||
inspect, err := c.docker.ContainerInspect(ctx, containerID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to inspect container: %w", err)
|
||||
}
|
||||
@@ -401,13 +354,13 @@ func (c *Client) IsContainerRunning(
|
||||
// IsContainerHealthy checks if a container is healthy.
|
||||
func (c *Client) IsContainerHealthy(
|
||||
ctx context.Context,
|
||||
containerID ContainerID,
|
||||
containerID string,
|
||||
) (bool, error) {
|
||||
if c.docker == nil {
|
||||
return false, ErrNotConnected
|
||||
}
|
||||
|
||||
inspect, err := c.docker.ContainerInspect(ctx, containerID.String())
|
||||
inspect, err := c.docker.ContainerInspect(ctx, containerID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to inspect container: %w", err)
|
||||
}
|
||||
@@ -425,7 +378,7 @@ const LabelUpaasID = "upaas.id"
|
||||
|
||||
// ContainerInfo contains basic information about a container.
|
||||
type ContainerInfo struct {
|
||||
ID ContainerID
|
||||
ID string
|
||||
Running bool
|
||||
}
|
||||
|
||||
@@ -460,7 +413,7 @@ func (c *Client) FindContainerByAppID(
|
||||
ctr := containers[0]
|
||||
|
||||
return &ContainerInfo{
|
||||
ID: ContainerID(ctr.ID),
|
||||
ID: ctr.ID,
|
||||
Running: ctr.State == "running",
|
||||
}, nil
|
||||
}
|
||||
@@ -483,8 +436,7 @@ type CloneResult struct {
|
||||
CommitSHA string // The HEAD commit SHA after clone/checkout
|
||||
}
|
||||
|
||||
// CloneRepo clones a git repository using SSH and optionally checks out a
|
||||
// specific commit.
|
||||
// CloneRepo clones a git repository using SSH and optionally checks out a specific commit.
|
||||
// containerDir is the path inside the upaas container (for writing files).
|
||||
// hostDir is the corresponding path on the Docker host (for bind mounts).
|
||||
// If commitSHA is provided, that specific commit will be checked out.
|
||||
@@ -528,73 +480,10 @@ func (c *Client) CloneRepo(
|
||||
return c.performClone(ctx, cfg)
|
||||
}
|
||||
|
||||
// RemoveImage removes a Docker image by ID or tag.
|
||||
// It returns nil if the image was successfully removed or does not exist.
|
||||
func (c *Client) RemoveImage(ctx context.Context, imageID ImageID) error {
|
||||
_, err := c.docker.ImageRemove(ctx, imageID.String(), image.RemoveOptions{
|
||||
Force: true,
|
||||
PruneChildren: true,
|
||||
})
|
||||
if err != nil && !client.IsErrNotFound(err) {
|
||||
return fmt.Errorf("failed to remove image %s: %w", imageID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListImageTags returns the tags in the given repository, such as
|
||||
// "upaas-myapp:12" in "upaas-myapp", each with the ID of its image.
|
||||
// Tags the same image has in other repositories are left out.
|
||||
func (c *Client) ListImageTags(
|
||||
ctx context.Context,
|
||||
repository string,
|
||||
) (map[string]ImageID, error) {
|
||||
if c.docker == nil {
|
||||
return nil, ErrNotConnected
|
||||
}
|
||||
|
||||
images, err := c.docker.ImageList(ctx, image.ListOptions{
|
||||
Filters: filters.NewArgs(filters.Arg("reference", repository)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list images: %w", err)
|
||||
}
|
||||
|
||||
tags := make(map[string]ImageID)
|
||||
|
||||
for _, img := range images {
|
||||
for _, tag := range img.RepoTags {
|
||||
if strings.HasPrefix(tag, repository+":") {
|
||||
tags[tag] = ImageID(img.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// RemoveImageTag removes a tag such as "upaas-myapp:12", without force.
|
||||
// Docker then deletes the image, and the untagged images it was built on,
|
||||
// only if no other tag and no container still uses it.
|
||||
func (c *Client) RemoveImageTag(ctx context.Context, tag string) error {
|
||||
if c.docker == nil {
|
||||
return ErrNotConnected
|
||||
}
|
||||
|
||||
_, err := c.docker.ImageRemove(ctx, tag, image.RemoveOptions{
|
||||
PruneChildren: true,
|
||||
})
|
||||
if err != nil && !client.IsErrNotFound(err) {
|
||||
return fmt.Errorf("failed to remove image tag %s: %w", tag, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) performBuild(
|
||||
ctx context.Context,
|
||||
opts BuildImageOptions,
|
||||
) (ImageID, error) {
|
||||
) (string, error) {
|
||||
// Create tar archive of build context
|
||||
tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{})
|
||||
if err != nil {
|
||||
@@ -608,11 +497,8 @@ func (c *Client) performBuild(
|
||||
}
|
||||
}()
|
||||
|
||||
// Build with BuildKit: the stages of a multi-stage build are kept in
|
||||
// its build cache, which Docker limits on its own, instead of being
|
||||
// left behind as untagged images.
|
||||
resp, err := c.docker.ImageBuild(ctx, tarArchive, dockertypes.ImageBuildOptions{
|
||||
Version: dockertypes.BuilderBuildKit,
|
||||
// Build image
|
||||
resp, err := c.docker.ImageBuild(ctx, tarArchive, types.ImageBuildOptions{
|
||||
Dockerfile: opts.DockerfilePath,
|
||||
Tags: opts.Tags,
|
||||
Remove: true,
|
||||
@@ -630,7 +516,7 @@ func (c *Client) performBuild(
|
||||
}()
|
||||
|
||||
// Stream build output line by line for real-time log updates
|
||||
err = c.streamBuildOutput(ctx, resp.Body, opts.LogWriter)
|
||||
err = c.streamBuildOutput(resp.Body, opts.LogWriter)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -642,7 +528,7 @@ func (c *Client) performBuild(
|
||||
return "", fmt.Errorf("failed to inspect image: %w", inspectErr)
|
||||
}
|
||||
|
||||
return ImageID(inspect.ID), nil
|
||||
return inspect.ID, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
@@ -651,70 +537,30 @@ func (c *Client) performBuild(
|
||||
// scannerInitialBufferSize is the initial buffer size for the build log scanner.
|
||||
const scannerInitialBufferSize = 64 * 1024 // 64KB
|
||||
|
||||
// scannerMaxBufferSize is the max buffer size for build log lines
|
||||
// (base64 layers can be large).
|
||||
// scannerMaxBufferSize is the max buffer size for build log lines (base64 layers can be large).
|
||||
const scannerMaxBufferSize = 1024 * 1024 // 1MB
|
||||
|
||||
// streamBuildOutput reads Docker build output line by line and writes it to
|
||||
// stdout and the optional log writer as it arrives. Docker sends
|
||||
// newline-delimited JSON. BuildKit's progress arrives encoded in
|
||||
// "moby.buildkit.trace" messages; these are decoded and written as plain
|
||||
// text, as "docker build --progress=plain" shows it. Other lines, such as
|
||||
// build errors, are written unchanged.
|
||||
func (c *Client) streamBuildOutput(
|
||||
ctx context.Context,
|
||||
body io.Reader,
|
||||
logWriter io.Writer,
|
||||
) error {
|
||||
out := io.Writer(os.Stdout)
|
||||
if logWriter != nil {
|
||||
out = io.MultiWriter(os.Stdout, logWriter)
|
||||
}
|
||||
|
||||
display, err := progressui.NewDisplay(out, progressui.PlainMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create build progress display: %w", err)
|
||||
}
|
||||
|
||||
statuses := make(chan *buildkitclient.SolveStatus)
|
||||
displayDone := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(displayDone)
|
||||
// The display must keep reading until statuses is closed, even
|
||||
// after ctx is cancelled, or the loop below would block.
|
||||
_, _ = display.UpdateFrom(context.WithoutCancel(ctx), statuses)
|
||||
}()
|
||||
|
||||
// streamBuildOutput reads Docker build output line by line and writes to stdout and optional log writer.
|
||||
// Docker sends newline-delimited JSON, so reading line by line ensures each log entry is written immediately.
|
||||
func (c *Client) streamBuildOutput(body io.Reader, logWriter io.Writer) error {
|
||||
scanner := bufio.NewScanner(body)
|
||||
buf := make([]byte, 0, scannerInitialBufferSize)
|
||||
scanner.Buffer(buf, scannerMaxBufferSize)
|
||||
|
||||
newline := []byte{'\n'}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
var msg jsonmessage.JSONMessage
|
||||
|
||||
err = json.Unmarshal(line, &msg)
|
||||
if err == nil && msg.ID == "moby.buildkit.trace" && msg.Aux != nil {
|
||||
var data []byte
|
||||
|
||||
var status controlapi.StatusResponse
|
||||
|
||||
if json.Unmarshal(*msg.Aux, &data) == nil && status.Unmarshal(data) == nil {
|
||||
statuses <- buildkitclient.NewSolveStatus(&status)
|
||||
}
|
||||
|
||||
continue
|
||||
// Write to stdout
|
||||
_, _ = os.Stdout.Write(line)
|
||||
_, _ = os.Stdout.Write(newline)
|
||||
// Write to log writer if provided
|
||||
if logWriter != nil {
|
||||
_, _ = logWriter.Write(line)
|
||||
_, _ = logWriter.Write(newline)
|
||||
}
|
||||
|
||||
// One write per line, so it is not split by the display's output.
|
||||
_, _ = fmt.Fprintf(out, "%s\n", line)
|
||||
}
|
||||
|
||||
close(statuses)
|
||||
<-displayDone
|
||||
|
||||
scanErr := scanner.Err()
|
||||
if scanErr != nil {
|
||||
return fmt.Errorf("failed to read build output: %w", scanErr)
|
||||
@@ -723,10 +569,7 @@ func (c *Client) streamBuildOutput(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) performClone(
|
||||
ctx context.Context,
|
||||
cfg *cloneConfig,
|
||||
) (*CloneResult, error) {
|
||||
func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResult, error) {
|
||||
// Create work directory for clone destination
|
||||
err := os.MkdirAll(cfg.containerDir, workDirPermissions)
|
||||
if err != nil {
|
||||
@@ -746,70 +589,22 @@ func (c *Client) performClone(
|
||||
}
|
||||
}()
|
||||
|
||||
gitContainerID, err := c.createGitContainer(ctx, cfg)
|
||||
containerID, err := c.createGitContainer(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The git image declares a volume, so Docker gives each clone container
|
||||
// an anonymous volume; remove it with the container. The removal must
|
||||
// still run when the deploy is cancelled.
|
||||
defer func() {
|
||||
_ = c.docker.ContainerRemove(
|
||||
context.WithoutCancel(ctx),
|
||||
gitContainerID.String(),
|
||||
container.RemoveOptions{Force: true, RemoveVolumes: true},
|
||||
)
|
||||
_ = c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true})
|
||||
}()
|
||||
|
||||
return c.runGitClone(ctx, gitContainerID)
|
||||
}
|
||||
|
||||
// ensureImage pulls ref if it is not already present locally. The pinned
|
||||
// digest is preserved: a pull of an image already present is a no-op, and a
|
||||
// missing one is fetched before it is used to create a container.
|
||||
func (c *Client) ensureImage(ctx context.Context, ref string) error {
|
||||
_, _, err := c.docker.ImageInspectWithRaw(ctx, ref)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !client.IsErrNotFound(err) {
|
||||
return fmt.Errorf("failed to inspect image %s: %w", ref, err)
|
||||
}
|
||||
|
||||
c.log.Info("pulling image", "image", ref)
|
||||
|
||||
reader, err := c.docker.ImagePull(ctx, ref, image.PullOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to pull image %s: %w", ref, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
closeErr := reader.Close()
|
||||
if closeErr != nil {
|
||||
c.log.Error("failed to close image pull reader", "error", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// The pull only completes once its response stream is fully drained.
|
||||
_, err = io.Copy(io.Discard, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to pull image %s: %w", ref, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return c.runGitClone(ctx, containerID)
|
||||
}
|
||||
|
||||
func (c *Client) createGitContainer(
|
||||
ctx context.Context,
|
||||
cfg *cloneConfig,
|
||||
) (ContainerID, error) {
|
||||
err := c.ensureImage(ctx, gitImage)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
) (string, error) {
|
||||
gitSSHCmd := "ssh -i /keys/deploy_key -o StrictHostKeyChecking=no"
|
||||
|
||||
// Build the git command using environment variables to avoid shell injection.
|
||||
@@ -838,8 +633,7 @@ func (c *Client) createGitContainer(
|
||||
entrypoint := []string{}
|
||||
cmd := []string{"sh", "-c", script}
|
||||
|
||||
// Use host paths for Docker bind mounts
|
||||
// (Docker runs on the host, not in our container)
|
||||
// Use host paths for Docker bind mounts (Docker runs on the host, not in our container)
|
||||
resp, err := c.docker.ContainerCreate(ctx,
|
||||
&container.Config{
|
||||
Image: gitImage,
|
||||
@@ -867,23 +661,16 @@ func (c *Client) createGitContainer(
|
||||
return "", fmt.Errorf("failed to create git container: %w", err)
|
||||
}
|
||||
|
||||
return ContainerID(resp.ID), nil
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
func (c *Client) runGitClone(
|
||||
ctx context.Context,
|
||||
containerID ContainerID,
|
||||
) (*CloneResult, error) {
|
||||
err := c.docker.ContainerStart(ctx, containerID.String(), container.StartOptions{})
|
||||
func (c *Client) runGitClone(ctx context.Context, containerID string) (*CloneResult, error) {
|
||||
err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start git container: %w", err)
|
||||
}
|
||||
|
||||
statusCh, errCh := c.docker.ContainerWait(
|
||||
ctx,
|
||||
containerID.String(),
|
||||
container.WaitConditionNotRunning,
|
||||
)
|
||||
statusCh, errCh := c.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
@@ -953,6 +740,20 @@ func (c *Client) connect(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveImage removes a Docker image by ID or tag.
|
||||
// It returns nil if the image was successfully removed or does not exist.
|
||||
func (c *Client) RemoveImage(ctx context.Context, imageID string) error {
|
||||
_, err := c.docker.ImageRemove(ctx, imageID, image.RemoveOptions{
|
||||
Force: true,
|
||||
PruneChildren: true,
|
||||
})
|
||||
if err != nil && !client.IsErrNotFound(err) {
|
||||
return fmt.Errorf("failed to remove image %s: %w", imageID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) close() error {
|
||||
if c.docker != nil {
|
||||
err := c.docker.Close()
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package docker //nolint:testpackage // tests unexported cpuLimitToNanoCPUs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCpuLimitToNanoCPUs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cpuLimit float64
|
||||
expected int64
|
||||
}{
|
||||
{"one core", 1.0, 1_000_000_000},
|
||||
{"half core", 0.5, 500_000_000},
|
||||
{"two cores", 2.0, 2_000_000_000},
|
||||
{"quarter core", 0.25, 250_000_000},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := cpuLimitToNanoCPUs(tt.cpuLimit)
|
||||
if got != tt.expected {
|
||||
t.Errorf("cpuLimitToNanoCPUs(%v) = %d, want %d", tt.cpuLimit, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package docker
|
||||
|
||||
// ImageID is a Docker image identifier (ID or tag).
|
||||
type ImageID string
|
||||
|
||||
// String implements the fmt.Stringer interface.
|
||||
func (id ImageID) String() string { return string(id) }
|
||||
|
||||
// ContainerID is a Docker container identifier.
|
||||
type ContainerID string
|
||||
|
||||
// String implements the fmt.Stringer interface.
|
||||
func (id ContainerID) String() string { return string(id) }
|
||||
@@ -1,32 +1,16 @@
|
||||
package docker //nolint:testpackage // tests unexported regexps and Client struct
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
controlapi "github.com/moby/buildkit/api/services/control"
|
||||
)
|
||||
|
||||
// mainBranch is the branch name used across validation tests.
|
||||
const mainBranch = "main"
|
||||
|
||||
func TestValidBranchRegex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
valid := []string{
|
||||
mainBranch,
|
||||
"main",
|
||||
"develop",
|
||||
"feature/my-feature",
|
||||
"release-1.0",
|
||||
@@ -86,7 +70,7 @@ func TestValidCommitSHARegex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneRepoRejectsInjection(t *testing.T) {
|
||||
func TestCloneRepoRejectsInjection(t *testing.T) { //nolint:funlen // table-driven test
|
||||
t.Parallel()
|
||||
|
||||
c := &Client{
|
||||
@@ -116,25 +100,25 @@ func TestCloneRepoRejectsInjection(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "injection in commitSHA",
|
||||
branch: mainBranch,
|
||||
branch: "main",
|
||||
commitSHA: "not-a-sha; rm -rf /",
|
||||
wantErr: ErrInvalidCommitSHA,
|
||||
},
|
||||
{
|
||||
name: "short SHA rejected",
|
||||
branch: mainBranch,
|
||||
branch: "main",
|
||||
commitSHA: "abc123",
|
||||
wantErr: ErrInvalidCommitSHA,
|
||||
},
|
||||
{
|
||||
name: "valid inputs pass validation (hit NotConnected)",
|
||||
branch: mainBranch,
|
||||
branch: "main",
|
||||
commitSHA: "abc123def456789012345678901234567890abcd",
|
||||
wantErr: ErrNotConnected,
|
||||
},
|
||||
{
|
||||
name: "valid branch no SHA passes validation (hit NotConnected)",
|
||||
branch: mainBranch,
|
||||
branch: "main",
|
||||
wantErr: ErrNotConnected,
|
||||
},
|
||||
}
|
||||
@@ -162,155 +146,3 @@ func TestCloneRepoRejectsInjection(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPerformCloneRemovesContainerVolumes runs a clone against a fake Docker
|
||||
// API and checks that the clone container is removed together with its
|
||||
// anonymous volumes, whether the clone succeeds, fails, or is cancelled.
|
||||
func TestPerformCloneRemovesContainerVolumes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
exitCode int
|
||||
cancel bool
|
||||
}{
|
||||
{name: "succeeds", exitCode: 0},
|
||||
{name: "fails", exitCode: 1},
|
||||
{name: "cancelled", cancel: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
removeQuery := make(chan url.Values, 1)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch {
|
||||
case r.Method == http.MethodDelete:
|
||||
removeQuery <- r.URL.Query()
|
||||
case strings.HasSuffix(r.URL.Path, "/containers/create"):
|
||||
_, _ = w.Write([]byte(`{"Id":"gitcontainer"}`))
|
||||
case strings.HasSuffix(r.URL.Path, "/wait") && tt.cancel:
|
||||
// Cancel the deploy while the clone is running.
|
||||
cancel()
|
||||
<-r.Context().Done()
|
||||
case strings.HasSuffix(r.URL.Path, "/wait"):
|
||||
_, _ = fmt.Fprintf(w, `{"StatusCode":%d}`, tt.exitCode)
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}
|
||||
},
|
||||
))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
dockerAPI, err := client.NewClientWithOpts(
|
||||
client.WithHost("tcp://" + srv.Listener.Addr().String()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := &Client{docker: dockerAPI, log: slog.Default()}
|
||||
|
||||
dir := t.TempDir()
|
||||
cfg := &cloneConfig{
|
||||
repoURL: "git@example.com:repo.git",
|
||||
branch: mainBranch,
|
||||
sshPrivateKey: "fake-key",
|
||||
containerDir: filepath.Join(dir, "repo"),
|
||||
hostDir: filepath.Join(dir, "repo"),
|
||||
keyFile: filepath.Join(dir, "deploy_key"),
|
||||
hostKeyFile: filepath.Join(dir, "deploy_key"),
|
||||
}
|
||||
|
||||
_, _ = c.performClone(ctx, cfg)
|
||||
|
||||
select {
|
||||
case query := <-removeQuery:
|
||||
if query.Get("v") != "1" {
|
||||
t.Errorf("clone container removed without its volumes: %v", query)
|
||||
}
|
||||
default:
|
||||
t.Error("clone container was not removed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPerformBuildUsesBuildKit runs a build against a fake Docker API and
|
||||
// checks that it asks for BuildKit and that BuildKit's progress reaches the
|
||||
// build log as plain text.
|
||||
func TestPerformBuildUsesBuildKit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
status := controlapi.StatusResponse{Vertexes: []*controlapi.Vertex{{
|
||||
Digest: "sha256:1111",
|
||||
Name: "[build 2/2] RUN make",
|
||||
Started: &now,
|
||||
Completed: &now,
|
||||
}}}
|
||||
|
||||
data, err := status.Marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
trace, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/build"):
|
||||
if r.URL.Query().Get("version") != "2" {
|
||||
http.Error(w, "not a BuildKit build", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(w, "{\"id\":\"moby.buildkit.trace\",\"aux\":%s}\n", trace)
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"Id":"sha256:built"}`))
|
||||
}
|
||||
},
|
||||
))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
dockerAPI, err := client.NewClientWithOpts(
|
||||
client.WithHost("tcp://" + srv.Listener.Addr().String()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := &Client{docker: dockerAPI, log: slog.Default()}
|
||||
|
||||
var buildLog bytes.Buffer
|
||||
|
||||
imageID, err := c.performBuild(t.Context(), BuildImageOptions{
|
||||
ContextDir: t.TempDir(),
|
||||
Tags: []string{"upaas-test:1"},
|
||||
LogWriter: &buildLog,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if imageID != "sha256:built" {
|
||||
t.Errorf("unexpected image ID %q", imageID)
|
||||
}
|
||||
|
||||
if !strings.Contains(buildLog.String(), "[build 2/2] RUN make") {
|
||||
t.Errorf("build log is missing the build step:\n%s", buildLog.String())
|
||||
}
|
||||
}
|
||||
|
||||
+149
-17
@@ -7,7 +7,8 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/app"
|
||||
)
|
||||
|
||||
// apiAppResponse is the JSON representation of an app.
|
||||
@@ -73,38 +74,40 @@ func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
|
||||
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
|
||||
// and sets a session cookie.
|
||||
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
var req map[string]string
|
||||
var req loginRequest
|
||||
|
||||
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
||||
if decodeErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{jsonKeyError: "invalid JSON body"},
|
||||
map[string]string{"error": "invalid JSON body"},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
username := req["username"]
|
||||
credential := req["password"]
|
||||
|
||||
if username == "" || credential == "" {
|
||||
if req.Username == "" || req.Password == "" {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{jsonKeyError: "username and password are required"},
|
||||
map[string]string{"error": "username and password are required"},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
user, authErr := h.auth.Authenticate(request.Context(), username, credential)
|
||||
user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password)
|
||||
if authErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{jsonKeyError: "invalid credentials"},
|
||||
map[string]string{"error": "invalid credentials"},
|
||||
http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
@@ -114,7 +117,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
||||
if sessionErr != nil {
|
||||
h.log.Error("api: failed to create session", "error", sessionErr)
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{jsonKeyError: "failed to create session"},
|
||||
map[string]string{"error": "failed to create session"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
@@ -133,7 +136,7 @@ func (h *Handlers) HandleAPIListApps() http.HandlerFunc {
|
||||
apps, err := h.appService.ListApps(request.Context())
|
||||
if err != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{jsonKeyError: "failed to list apps"},
|
||||
map[string]string{"error": "failed to list apps"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
@@ -156,7 +159,7 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
|
||||
application, err := h.appService.GetApp(request.Context(), appID)
|
||||
if err != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{jsonKeyError: "internal server error"},
|
||||
map[string]string{"error": "internal server error"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
@@ -164,7 +167,7 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
|
||||
|
||||
if application == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{jsonKeyError: "app not found"},
|
||||
map[string]string{"error": "app not found"},
|
||||
http.StatusNotFound)
|
||||
|
||||
return
|
||||
@@ -174,6 +177,106 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPICreateApp returns a handler that creates a new app.
|
||||
func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
|
||||
type createRequest struct {
|
||||
Name string `json:"name"`
|
||||
RepoURL string `json:"repoUrl"`
|
||||
Branch string `json:"branch"`
|
||||
DockerfilePath string `json:"dockerfilePath"`
|
||||
DockerNetwork string `json:"dockerNetwork"`
|
||||
NtfyTopic string `json:"ntfyTopic"`
|
||||
SlackWebhook string `json:"slackWebhook"`
|
||||
}
|
||||
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
var req createRequest
|
||||
|
||||
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
||||
if decodeErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "invalid JSON body"},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" || req.RepoURL == "" {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "name and repo_url are required"},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
nameErr := validateAppName(req.Name)
|
||||
if nameErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "invalid app name: " + nameErr.Error()},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{
|
||||
Name: req.Name,
|
||||
RepoURL: req.RepoURL,
|
||||
Branch: req.Branch,
|
||||
DockerfilePath: req.DockerfilePath,
|
||||
DockerNetwork: req.DockerNetwork,
|
||||
NtfyTopic: req.NtfyTopic,
|
||||
SlackWebhook: req.SlackWebhook,
|
||||
})
|
||||
if createErr != nil {
|
||||
h.log.Error("api: failed to create app", "error", createErr)
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "failed to create app"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request, appToAPI(createdApp), http.StatusCreated)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDeleteApp returns a handler that deletes an app.
|
||||
func (h *Handlers) HandleAPIDeleteApp() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
|
||||
application, err := h.appService.GetApp(request.Context(), appID)
|
||||
if err != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "internal server error"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if application == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "app not found"},
|
||||
http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deleteErr := h.appService.DeleteApp(request.Context(), application)
|
||||
if deleteErr != nil {
|
||||
h.log.Error("api: failed to delete app", "error", deleteErr)
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "failed to delete app"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"status": "deleted"}, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// deploymentsPageLimit is the default number of deployments per page.
|
||||
const deploymentsPageLimit = 20
|
||||
|
||||
@@ -185,7 +288,7 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
|
||||
application, err := h.appService.GetApp(request.Context(), appID)
|
||||
if err != nil || application == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{jsonKeyError: "app not found"},
|
||||
map[string]string{"error": "app not found"},
|
||||
http.StatusNotFound)
|
||||
|
||||
return
|
||||
@@ -205,7 +308,7 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
|
||||
)
|
||||
if deployErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{jsonKeyError: "failed to list deployments"},
|
||||
map[string]string{"error": "failed to list deployments"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
@@ -220,6 +323,35 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPITriggerDeploy returns a handler that triggers a deployment for an app.
|
||||
func (h *Handlers) HandleAPITriggerDeploy() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
|
||||
application, err := h.appService.GetApp(request.Context(), appID)
|
||||
if err != nil || application == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "app not found"},
|
||||
http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deployErr := h.deploy.Deploy(request.Context(), application, nil, true)
|
||||
if deployErr != nil {
|
||||
h.log.Error("api: failed to trigger deploy", "error", deployErr)
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": deployErr.Error()},
|
||||
http.StatusConflict)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"status": "deploying"}, http.StatusAccepted)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIWhoAmI returns a handler that shows the current authenticated user.
|
||||
func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
|
||||
type whoAmIResponse struct {
|
||||
@@ -231,7 +363,7 @@ func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
|
||||
user, err := h.auth.GetCurrentUser(request.Context(), request)
|
||||
if err != nil || user == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{jsonKeyError: "unauthorized"},
|
||||
map[string]string{"error": "unauthorized"},
|
||||
http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/service/app"
|
||||
)
|
||||
|
||||
// apiRouter builds a chi router with the API routes using session auth middleware.
|
||||
@@ -25,7 +23,10 @@ func apiRouter(tc *testContext) http.Handler {
|
||||
apiR.Use(tc.middleware.APISessionAuth())
|
||||
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
|
||||
apiR.Get("/apps", tc.handlers.HandleAPIListApps())
|
||||
apiR.Post("/apps", tc.handlers.HandleAPICreateApp())
|
||||
apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp())
|
||||
apiR.Delete("/apps/{id}", tc.handlers.HandleAPIDeleteApp())
|
||||
apiR.Post("/apps/{id}/deploy", tc.handlers.HandleAPITriggerDeploy())
|
||||
apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments())
|
||||
})
|
||||
})
|
||||
@@ -47,12 +48,7 @@ func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
|
||||
r := apiRouter(tc)
|
||||
|
||||
loginBody := `{"username":"admin","password":"password123"}`
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/api/v1/login",
|
||||
strings.NewReader(loginBody),
|
||||
)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -66,16 +62,23 @@ func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
|
||||
return tc, cookies
|
||||
}
|
||||
|
||||
// apiGet makes an authenticated GET request using session cookies.
|
||||
func apiGet(
|
||||
// apiRequest makes an authenticated API request using session cookies.
|
||||
func apiRequest(
|
||||
t *testing.T,
|
||||
tc *testContext,
|
||||
cookies []*http.Cookie,
|
||||
path string,
|
||||
method, path string,
|
||||
body string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
|
||||
var req *http.Request
|
||||
if body != "" {
|
||||
req = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
@@ -100,12 +103,7 @@ func TestAPILoginSuccess(t *testing.T) {
|
||||
r := apiRouter(tc)
|
||||
|
||||
body := `{"username":"admin","password":"password123"}`
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/api/v1/login",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -132,12 +130,7 @@ func TestAPILoginInvalidCredentials(t *testing.T) {
|
||||
r := apiRouter(tc)
|
||||
|
||||
body := `{"username":"admin","password":"wrong"}`
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/api/v1/login",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -154,12 +147,7 @@ func TestAPILoginMissingFields(t *testing.T) {
|
||||
r := apiRouter(tc)
|
||||
|
||||
body := `{"username":"","password":""}`
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/api/v1/login",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -175,9 +163,7 @@ func TestAPIRejectsUnauthenticated(t *testing.T) {
|
||||
|
||||
r := apiRouter(tc)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/api/v1/apps", nil,
|
||||
)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
@@ -189,7 +175,7 @@ func TestAPIWhoAmI(t *testing.T) {
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
rr := apiGet(t, tc, cookies, "/api/v1/whoami")
|
||||
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/whoami", "")
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var resp map[string]any
|
||||
@@ -202,7 +188,7 @@ func TestAPIListAppsEmpty(t *testing.T) {
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
rr := apiGet(t, tc, cookies, "/api/v1/apps")
|
||||
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps", "")
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var apps []any
|
||||
@@ -210,23 +196,52 @@ func TestAPIListAppsEmpty(t *testing.T) {
|
||||
assert.Empty(t, apps)
|
||||
}
|
||||
|
||||
func TestAPICreateApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
body := `{"name":"test-app","repoUrl":"https://github.com/example/repo"}`
|
||||
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
|
||||
assert.Equal(t, http.StatusCreated, rr.Code)
|
||||
|
||||
var app map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
|
||||
assert.Equal(t, "test-app", app["name"])
|
||||
assert.Equal(t, "pending", app["status"])
|
||||
}
|
||||
|
||||
func TestAPICreateAppValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
body := `{"name":"","repoUrl":""}`
|
||||
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
}
|
||||
|
||||
func TestAPIGetApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
created, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{
|
||||
Name: "my-app",
|
||||
RepoURL: "https://github.com/example/repo",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
body := `{"name":"my-app","repoUrl":"https://github.com/example/repo"}`
|
||||
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
|
||||
require.Equal(t, http.StatusCreated, rr.Code)
|
||||
|
||||
rr := apiGet(t, tc, cookies, "/api/v1/apps/"+created.ID)
|
||||
var created map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
|
||||
|
||||
appID, ok := created["id"].(string)
|
||||
require.True(t, ok)
|
||||
|
||||
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "my-app", resp["name"])
|
||||
var app map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
|
||||
assert.Equal(t, "my-app", app["name"])
|
||||
}
|
||||
|
||||
func TestAPIGetAppNotFound(t *testing.T) {
|
||||
@@ -234,7 +249,29 @@ func TestAPIGetAppNotFound(t *testing.T) {
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
rr := apiGet(t, tc, cookies, "/api/v1/apps/nonexistent")
|
||||
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/nonexistent", "")
|
||||
assert.Equal(t, http.StatusNotFound, rr.Code)
|
||||
}
|
||||
|
||||
func TestAPIDeleteApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
body := `{"name":"delete-me","repoUrl":"https://github.com/example/repo"}`
|
||||
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
|
||||
require.Equal(t, http.StatusCreated, rr.Code)
|
||||
|
||||
var created map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
|
||||
|
||||
appID, ok := created["id"].(string)
|
||||
require.True(t, ok)
|
||||
|
||||
rr = apiRequest(t, tc, cookies, http.MethodDelete, "/api/v1/apps/"+appID, "")
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
|
||||
assert.Equal(t, http.StatusNotFound, rr.Code)
|
||||
}
|
||||
|
||||
@@ -243,13 +280,17 @@ func TestAPIListDeployments(t *testing.T) {
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
created, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{
|
||||
Name: "deploy-app",
|
||||
RepoURL: "https://github.com/example/repo",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
body := `{"name":"deploy-app","repoUrl":"https://github.com/example/repo"}`
|
||||
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
|
||||
require.Equal(t, http.StatusCreated, rr.Code)
|
||||
|
||||
rr := apiGet(t, tc, cookies, "/api/v1/apps/"+created.ID+"/deployments")
|
||||
var created map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
|
||||
|
||||
appID, ok := created["id"].(string)
|
||||
require.True(t, ok)
|
||||
|
||||
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID+"/deployments", "")
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var deployments []any
|
||||
|
||||
+232
-442
File diff suppressed because it is too large
Load Diff
@@ -21,16 +21,8 @@ func TestValidateAppName(t *testing.T) {
|
||||
{"empty", "", true},
|
||||
{"single char", "a", true},
|
||||
{"too long", "a" + string(make([]byte, 63)), true},
|
||||
{
|
||||
"exactly 63 chars",
|
||||
"a23456789012345678901234567890123456789012345678901234567890123",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"64 chars",
|
||||
"a234567890123456789012345678901234567890123456789012345678901234",
|
||||
true,
|
||||
},
|
||||
{"exactly 63 chars", "a23456789012345678901234567890123456789012345678901234567890123", false},
|
||||
{"64 chars", "a234567890123456789012345678901234567890123456789012345678901234", true},
|
||||
{"uppercase", "MyApp", true},
|
||||
{"spaces", "my app", true},
|
||||
{"starts with hyphen", "-myapp", true},
|
||||
|
||||
@@ -3,7 +3,7 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"sneak.berlin/go/upaas/templates"
|
||||
"git.eeqj.de/sneak/upaas/templates"
|
||||
)
|
||||
|
||||
// HandleLoginGET returns the login page handler.
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"sneak.berlin/go/upaas/templates"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/templates"
|
||||
)
|
||||
|
||||
// AppStats holds deployment statistics for an app.
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
package handlers
|
||||
|
||||
// ValidateRepoURLForTest exports validateRepoURL for testing.
|
||||
func ValidateRepoURLForTest(repoURL string) error {
|
||||
return validateRepoURL(repoURL)
|
||||
}
|
||||
@@ -10,29 +10,16 @@ import (
|
||||
"github.com/gorilla/csrf"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"sneak.berlin/go/upaas/internal/docker"
|
||||
"sneak.berlin/go/upaas/internal/globals"
|
||||
"sneak.berlin/go/upaas/internal/healthcheck"
|
||||
"sneak.berlin/go/upaas/internal/logger"
|
||||
"sneak.berlin/go/upaas/internal/service/app"
|
||||
"sneak.berlin/go/upaas/internal/service/auth"
|
||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
||||
"sneak.berlin/go/upaas/internal/service/webhook"
|
||||
"sneak.berlin/go/upaas/templates"
|
||||
)
|
||||
|
||||
// Template data keys shared across handlers.
|
||||
const (
|
||||
dataKeyApp = "App"
|
||||
dataKeyError = "Error"
|
||||
)
|
||||
|
||||
// JSON response keys shared across handlers.
|
||||
const (
|
||||
jsonKeyError = "error"
|
||||
jsonKeyLogs = "logs"
|
||||
jsonKeyStatus = "status"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/docker"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/app"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/webhook"
|
||||
"git.eeqj.de/sneak/upaas/templates"
|
||||
)
|
||||
|
||||
// Params contains dependencies for Handlers.
|
||||
|
||||
@@ -8,32 +8,28 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"sneak.berlin/go/upaas/internal/docker"
|
||||
"sneak.berlin/go/upaas/internal/globals"
|
||||
"sneak.berlin/go/upaas/internal/handlers"
|
||||
"sneak.berlin/go/upaas/internal/healthcheck"
|
||||
"sneak.berlin/go/upaas/internal/logger"
|
||||
"sneak.berlin/go/upaas/internal/middleware"
|
||||
"sneak.berlin/go/upaas/internal/service/app"
|
||||
"sneak.berlin/go/upaas/internal/service/auth"
|
||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
||||
"sneak.berlin/go/upaas/internal/service/notify"
|
||||
"sneak.berlin/go/upaas/internal/service/webhook"
|
||||
)
|
||||
|
||||
const (
|
||||
branchMain = "main"
|
||||
paramSecret = "secret"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/docker"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/handlers"
|
||||
"git.eeqj.de/sneak/upaas/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/middleware"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/app"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/notify"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/webhook"
|
||||
)
|
||||
|
||||
type testContext struct {
|
||||
@@ -41,8 +37,6 @@ type testContext struct {
|
||||
database *database.Database
|
||||
authSvc *auth.Service
|
||||
appSvc *app.Service
|
||||
deploySvc *deploy.Service
|
||||
webhookSvc *webhook.Service
|
||||
middleware *middleware.Middleware
|
||||
}
|
||||
|
||||
@@ -187,8 +181,6 @@ func setupTestHandlers(t *testing.T) *testContext {
|
||||
database: dbInstance,
|
||||
authSvc: authSvc,
|
||||
appSvc: appSvc,
|
||||
deploySvc: deploySvc,
|
||||
webhookSvc: webhookSvc,
|
||||
middleware: mw,
|
||||
}
|
||||
}
|
||||
@@ -201,8 +193,7 @@ func TestHandleHealthCheck(t *testing.T) {
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/.well-known/healthcheck.json",
|
||||
nil,
|
||||
@@ -219,26 +210,6 @@ func TestHandleHealthCheck(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// assertPageRenders serves a GET request for path with the given
|
||||
// handler and asserts a 200 response containing want.
|
||||
func assertPageRenders(
|
||||
t *testing.T,
|
||||
handler http.Handler,
|
||||
path, want string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, path, nil,
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), want)
|
||||
}
|
||||
|
||||
func TestHandleSetupGET(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -246,7 +217,15 @@ func TestHandleSetupGET(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
assertPageRenders(t, testCtx.handlers.HandleSetupGET(), "/setup", "setup")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/setup", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleSetupGET()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "setup")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -258,8 +237,7 @@ func createSetupFormRequest(
|
||||
form.Set("password", password)
|
||||
form.Set("password_confirm", confirm)
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/setup",
|
||||
strings.NewReader(form.Encode()),
|
||||
@@ -336,7 +314,15 @@ func TestHandleLoginGET(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
assertPageRenders(t, testCtx.handlers.HandleLoginGET(), "/login", "login")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleLoginGET()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "login")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -345,8 +331,7 @@ func createLoginFormRequest(username, password string) *http.Request {
|
||||
form.Set("username", username)
|
||||
form.Set("password", password)
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/login",
|
||||
strings.NewReader(form.Encode()),
|
||||
@@ -410,9 +395,7 @@ func TestHandleDashboard(t *testing.T) {
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
)
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleDashboard()
|
||||
@@ -421,27 +404,6 @@ func TestHandleDashboard(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "Applications")
|
||||
})
|
||||
|
||||
t.Run("renders dashboard with apps without crashing on CSRFField", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
// Create an app so the template iterates over AppStats and hits .CSRFField
|
||||
createTestApp(t, testCtx, "csrf-test-app")
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleDashboard()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code,
|
||||
"dashboard should not 500 when apps exist (CSRFField must be accessible)")
|
||||
assert.Contains(t, recorder.Body.String(), "csrf-test-app")
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleAppNew(t *testing.T) {
|
||||
@@ -452,9 +414,7 @@ func TestHandleAppNew(t *testing.T) {
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/apps/new", nil,
|
||||
)
|
||||
request := httptest.NewRequest(http.MethodGet, "/apps/new", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleAppNew()
|
||||
@@ -493,7 +453,7 @@ func createTestApp(
|
||||
app.CreateAppInput{
|
||||
Name: name,
|
||||
RepoURL: "git@example.com:user/" + name + ".git",
|
||||
Branch: branchMain,
|
||||
Branch: "main",
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
@@ -514,7 +474,7 @@ func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
|
||||
app.CreateAppInput{
|
||||
Name: "oversize-test-app",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
Branch: branchMain,
|
||||
Branch: "main",
|
||||
},
|
||||
)
|
||||
require.NoError(t, createErr)
|
||||
@@ -522,15 +482,14 @@ func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
|
||||
// Create a body larger than 1MB - it should be silently truncated
|
||||
// and the webhook should still process (or fail gracefully on parse)
|
||||
largePayload := strings.Repeat("x", 2*1024*1024) // 2MB
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/"+createdApp.WebhookSecret,
|
||||
strings.NewReader(largePayload),
|
||||
)
|
||||
request = addChiURLParams(
|
||||
request,
|
||||
map[string]string{paramSecret: createdApp.WebhookSecret},
|
||||
map[string]string{"secret": createdApp.WebhookSecret},
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Gitea-Event", "push")
|
||||
@@ -566,8 +525,7 @@ func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) {
|
||||
|
||||
resourceID := cfg.createFn(t, testCtx, app1)
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
cfg.deletePath(app2.ID, resourceID),
|
||||
nil,
|
||||
@@ -583,249 +541,45 @@ func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) {
|
||||
cfg.verifyFn(t, testCtx, resourceID)
|
||||
}
|
||||
|
||||
// TestHandleEnvVarSaveBulk tests that HandleEnvVarSave replaces all env vars
|
||||
// for an app with the submitted set (monolithic delete-all + insert-all).
|
||||
func TestHandleEnvVarSaveBulk(t *testing.T) {
|
||||
// TestDeleteEnvVarOwnershipVerification tests that deleting an env var
|
||||
// via another app's URL path returns 404 (IDOR prevention).
|
||||
func TestDeleteEnvVarOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
createdApp := createTestApp(t, testCtx, "envvar-bulk-app")
|
||||
testOwnershipVerification(t, ownedResourceTestConfig{
|
||||
appPrefix1: "envvar-owner-app",
|
||||
appPrefix2: "envvar-other-app",
|
||||
createFn: func(t *testing.T, tc *testContext, ownerApp *models.App) int64 {
|
||||
t.Helper()
|
||||
|
||||
// Create some pre-existing env vars
|
||||
for _, kv := range [][2]string{{"OLD_KEY", "old_value"}, {"REMOVE_ME", "gone"}} {
|
||||
ev := models.NewEnvVar(testCtx.database)
|
||||
ev.AppID = createdApp.ID
|
||||
ev.Key = kv[0]
|
||||
ev.Value = kv[1]
|
||||
require.NoError(t, ev.Save(context.Background()))
|
||||
}
|
||||
envVar := models.NewEnvVar(tc.database)
|
||||
envVar.AppID = ownerApp.ID
|
||||
envVar.Key = "SECRET"
|
||||
envVar.Value = "hunter2"
|
||||
require.NoError(t, envVar.Save(context.Background()))
|
||||
|
||||
// Submit a new set as a JSON array of key/value objects
|
||||
body := `[{"key":"NEW_KEY","value":"new_value"},{"key":"ANOTHER","value":"42"}]`
|
||||
return envVar.ID
|
||||
},
|
||||
deletePath: func(appID string, resourceID int64) string {
|
||||
return "/apps/" + appID + "/env/" + strconv.FormatInt(resourceID, 10) + "/delete"
|
||||
},
|
||||
chiParams: func(appID string, resourceID int64) map[string]string {
|
||||
return map[string]string{"id": appID, "envID": strconv.FormatInt(resourceID, 10)}
|
||||
},
|
||||
handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleEnvVarDelete() },
|
||||
verifyFn: func(t *testing.T, tc *testContext, resourceID int64) {
|
||||
t.Helper()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+createdApp.ID+"/env",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
r.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
// Verify old env vars are gone and new ones exist
|
||||
envVars, err := models.FindEnvVarsByAppID(
|
||||
context.Background(), testCtx.database, createdApp.ID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 2)
|
||||
|
||||
keys := make(map[string]string)
|
||||
for _, ev := range envVars {
|
||||
keys[ev.Key] = ev.Value
|
||||
}
|
||||
|
||||
assert.Equal(t, "new_value", keys["NEW_KEY"])
|
||||
assert.Equal(t, "42", keys["ANOTHER"])
|
||||
assert.Empty(t, keys["OLD_KEY"], "old env vars should be deleted")
|
||||
assert.Empty(t, keys["REMOVE_ME"], "old env vars should be deleted")
|
||||
}
|
||||
|
||||
// TestHandleEnvVarSaveAppNotFound tests that HandleEnvVarSave returns 404
|
||||
// for a non-existent app.
|
||||
func TestHandleEnvVarSaveAppNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
body := `[{"key":"KEY","value":"value"}]`
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/nonexistent-id/env",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
r.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
}
|
||||
|
||||
// TestHandleEnvVarSaveEmptyKeyRejected verifies that submitting a JSON
|
||||
// array containing an entry with an empty key returns 400.
|
||||
func TestHandleEnvVarSaveEmptyKeyRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
createdApp := createTestApp(t, testCtx, "envvar-emptykey-app")
|
||||
|
||||
body := `[{"key":"VALID_KEY","value":"ok"},{"key":"","value":"bad"}]`
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+createdApp.ID+"/env",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
r.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
|
||||
// TestHandleEnvVarSaveDuplicateKeyRejected verifies that when the client
|
||||
// sends duplicate keys, the server rejects them with 400 Bad Request.
|
||||
func TestHandleEnvVarSaveDuplicateKeyRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
createdApp := createTestApp(t, testCtx, "envvar-dedup-app")
|
||||
|
||||
// Send two entries with the same key — should be rejected
|
||||
body := `[{"key":"FOO","value":"first"},{"key":"BAR","value":"bar"},` +
|
||||
`{"key":"FOO","value":"second"}]`
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+createdApp.ID+"/env",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
r.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "duplicate environment variable key: FOO")
|
||||
}
|
||||
|
||||
// TestHandleEnvVarSaveCrossAppIsolation verifies that posting env vars
|
||||
// to appA's endpoint does not affect appB's env vars (IDOR prevention).
|
||||
func TestHandleEnvVarSaveCrossAppIsolation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
appA := createTestApp(t, testCtx, "envvar-iso-appA")
|
||||
appB := createTestApp(t, testCtx, "envvar-iso-appB")
|
||||
|
||||
// Give appB some env vars
|
||||
for _, kv := range [][2]string{{"B_KEY1", "b_val1"}, {"B_KEY2", "b_val2"}} {
|
||||
ev := models.NewEnvVar(testCtx.database)
|
||||
ev.AppID = appB.ID
|
||||
ev.Key = kv[0]
|
||||
ev.Value = kv[1]
|
||||
require.NoError(t, ev.Save(context.Background()))
|
||||
}
|
||||
|
||||
// POST new env vars to appA's endpoint
|
||||
body := `[{"key":"A_KEY","value":"a_val"}]`
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+appA.ID+"/env",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
r.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
// Verify appA has exactly what we sent
|
||||
appAVars, err := models.FindEnvVarsByAppID(
|
||||
context.Background(), testCtx.database, appA.ID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, appAVars, 1)
|
||||
assert.Equal(t, "A_KEY", appAVars[0].Key)
|
||||
|
||||
// Verify appB's env vars are completely untouched
|
||||
appBVars, err := models.FindEnvVarsByAppID(
|
||||
context.Background(), testCtx.database, appB.ID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, appBVars, 2, "appB env vars must not be affected")
|
||||
|
||||
bKeys := make(map[string]string)
|
||||
for _, ev := range appBVars {
|
||||
bKeys[ev.Key] = ev.Value
|
||||
}
|
||||
|
||||
assert.Equal(t, "b_val1", bKeys["B_KEY1"])
|
||||
assert.Equal(t, "b_val2", bKeys["B_KEY2"])
|
||||
}
|
||||
|
||||
// TestHandleEnvVarSaveBodySizeLimit verifies that a request body
|
||||
// exceeding the 1 MB limit is rejected.
|
||||
func TestHandleEnvVarSaveBodySizeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
createdApp := createTestApp(t, testCtx, "envvar-sizelimit-app")
|
||||
|
||||
// Build a JSON body that exceeds 1 MB
|
||||
// Each entry is ~30 bytes; 40000 entries ≈ 1.2 MB
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("[")
|
||||
|
||||
for i := range 40000 {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
|
||||
sb.WriteString(`{"key":"K` + strconv.Itoa(i) + `","value":"val"}`)
|
||||
}
|
||||
|
||||
sb.WriteString("]")
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+createdApp.ID+"/env",
|
||||
strings.NewReader(sb.String()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
r.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code,
|
||||
"oversized body should be rejected with 400")
|
||||
found, findErr := models.FindEnvVar(context.Background(), tc.database, resourceID)
|
||||
require.NoError(t, findErr)
|
||||
assert.NotNil(t, found, "env var should still exist after IDOR attempt")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeleteLabelOwnershipVerification tests that deleting a label
|
||||
// via another app's URL path returns 404 (IDOR prevention).
|
||||
func TestDeleteLabelOwnershipVerification(t *testing.T) {
|
||||
func TestDeleteLabelOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern
|
||||
t.Parallel()
|
||||
|
||||
testOwnershipVerification(t, ownedResourceTestConfig{
|
||||
@@ -878,8 +632,7 @@ func TestDeleteVolumeOwnershipVerification(t *testing.T) {
|
||||
require.NoError(t, volume.Save(context.Background()))
|
||||
|
||||
// Try to delete app1's volume using app2's URL path
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/apps/"+app2.ID+"/volumes/"+strconv.FormatInt(volume.ID, 10)+"/delete",
|
||||
nil,
|
||||
@@ -920,8 +673,7 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
|
||||
require.NoError(t, port.Save(context.Background()))
|
||||
|
||||
// Try to delete app1's port using app2's URL path
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/apps/"+app2.ID+"/ports/"+strconv.FormatInt(port.ID, 10)+"/delete",
|
||||
nil,
|
||||
@@ -943,168 +695,6 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
|
||||
assert.NotNil(t, found, "port should still exist after IDOR attempt")
|
||||
}
|
||||
|
||||
// TestHandleEnvVarSaveEmptyClears verifies that submitting an empty JSON
|
||||
// array deletes all existing env vars for the app.
|
||||
func TestHandleEnvVarSaveEmptyClears(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
createdApp := createTestApp(t, testCtx, "envvar-clear-app")
|
||||
|
||||
// Create a pre-existing env var
|
||||
ev := models.NewEnvVar(testCtx.database)
|
||||
ev.AppID = createdApp.ID
|
||||
ev.Key = "DELETE_ME"
|
||||
ev.Value = "gone"
|
||||
require.NoError(t, ev.Save(context.Background()))
|
||||
|
||||
// Submit empty JSON array
|
||||
r := chi.NewRouter()
|
||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+createdApp.ID+"/env",
|
||||
strings.NewReader("[]"),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
r.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
// Verify all env vars are gone
|
||||
envVars, err := models.FindEnvVarsByAppID(
|
||||
context.Background(), testCtx.database, createdApp.ID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, envVars, "all env vars should be deleted")
|
||||
}
|
||||
|
||||
// TestHandleVolumeAddValidatesPaths verifies that HandleVolumeAdd validates
|
||||
// host and container paths (same as HandleVolumeEdit).
|
||||
func TestHandleVolumeAddValidatesPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
createdApp := createTestApp(t, testCtx, "volume-validate-app")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hostPath string
|
||||
containerPath string
|
||||
shouldCreate bool
|
||||
}{
|
||||
{"relative host path rejected", "relative/path", "/container", false},
|
||||
{"relative container path rejected", "/host", "relative/path", false},
|
||||
{"unclean host path rejected", "/host/../etc", "/container", false},
|
||||
{"valid paths accepted", "/host/data", "/container/data", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("host_path", tt.hostPath)
|
||||
form.Set("container_path", tt.containerPath)
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+createdApp.ID+"/volumes",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleVolumeAdd()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, recorder.Code)
|
||||
|
||||
// Check if volume was created by listing volumes
|
||||
volumes, _ := createdApp.GetVolumes(context.Background())
|
||||
found := false
|
||||
|
||||
for _, v := range volumes {
|
||||
if v.HostPath == tt.hostPath && v.ContainerPath == tt.containerPath {
|
||||
found = true
|
||||
// Clean up for isolation
|
||||
_ = v.Delete(context.Background())
|
||||
}
|
||||
}
|
||||
|
||||
if tt.shouldCreate {
|
||||
assert.True(t, found, "volume should be created for valid paths")
|
||||
} else {
|
||||
assert.False(t, found, "volume should NOT be created for invalid paths")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupRequiredExemptsHealthAndStaticAndAPI verifies that the SetupRequired
|
||||
// middleware allows /health, /s/*, and /api/* paths through even when setup is
|
||||
// required.
|
||||
func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
// No user created, so setup IS required
|
||||
mw := testCtx.middleware.SetupRequired()
|
||||
|
||||
okHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
wrapped := mw(okHandler)
|
||||
|
||||
exemptPaths := []string{
|
||||
"/health",
|
||||
"/s/style.css",
|
||||
"/s/js/app.js",
|
||||
"/api/v1/apps",
|
||||
"/api/v1/login",
|
||||
}
|
||||
|
||||
for _, path := range exemptPaths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, path, nil,
|
||||
)
|
||||
rr := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code,
|
||||
"path %s should be exempt from setup redirect", path)
|
||||
})
|
||||
}
|
||||
|
||||
// Non-exempt path should redirect to /setup
|
||||
t.Run("non-exempt redirects", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
)
|
||||
rr := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, rr.Code)
|
||||
assert.Equal(t, "/setup", rr.Header().Get("Location"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleCancelDeployRedirects(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1112,8 +702,7 @@ func TestHandleCancelDeployRedirects(t *testing.T) {
|
||||
|
||||
createdApp := createTestApp(t, testCtx, "cancel-deploy-app")
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/apps/"+createdApp.ID+"/deployments/cancel",
|
||||
nil,
|
||||
@@ -1133,8 +722,7 @@ func TestHandleCancelDeployReturns404ForUnknownApp(t *testing.T) {
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/apps/nonexistent/deployments/cancel",
|
||||
nil,
|
||||
@@ -1155,16 +743,12 @@ func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
|
||||
|
||||
webhookURL := "/webhook/unknown-secret"
|
||||
payload := `{"ref": "refs/heads/main"}`
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
webhookURL,
|
||||
strings.NewReader(payload),
|
||||
)
|
||||
request = addChiURLParams(
|
||||
request,
|
||||
map[string]string{paramSecret: "unknown-secret"},
|
||||
)
|
||||
request = addChiURLParams(request, map[string]string{"secret": "unknown-secret"})
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Gitea-Event", "push")
|
||||
|
||||
@@ -1187,22 +771,21 @@ func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
|
||||
app.CreateAppInput{
|
||||
Name: "webhook-test-app",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
Branch: branchMain,
|
||||
Branch: "main",
|
||||
},
|
||||
)
|
||||
require.NoError(t, createErr)
|
||||
|
||||
payload := `{"ref": "refs/heads/main", "after": "abc123"}`
|
||||
webhookURL := "/webhook/" + createdApp.WebhookSecret
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
webhookURL,
|
||||
strings.NewReader(payload),
|
||||
)
|
||||
request = addChiURLParams(
|
||||
request,
|
||||
map[string]string{paramSecret: createdApp.WebhookSecret},
|
||||
map[string]string{"secret": createdApp.WebhookSecret},
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Gitea-Event", "push")
|
||||
@@ -1214,7 +797,8 @@ func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
// Wait for the async deployment goroutine to finish so its writes
|
||||
// under the temp dir complete before test cleanup.
|
||||
testCtx.webhookSvc.WaitForDeployments()
|
||||
// Allow async deployment goroutine to complete before test cleanup.
|
||||
// The deployment will fail quickly (docker not connected) but we need
|
||||
// to wait for it to finish to avoid temp directory cleanup race.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
)
|
||||
|
||||
// doLogDownload issues a log-download request for the given app and
|
||||
// deployment and returns the recorder.
|
||||
func doLogDownload(
|
||||
t *testing.T,
|
||||
testCtx *testContext,
|
||||
appID string,
|
||||
deploymentID int64,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
idStr := strconv.FormatInt(deploymentID, 10)
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodGet,
|
||||
"/apps/"+appID+"/deployments/"+idStr+"/log",
|
||||
nil,
|
||||
)
|
||||
request = addChiURLParams(request, map[string]string{
|
||||
"id": appID,
|
||||
"deploymentID": idStr,
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
testCtx.handlers.HandleDeploymentLogDownload().ServeHTTP(recorder, request)
|
||||
|
||||
return recorder
|
||||
}
|
||||
|
||||
// TestHandleDeploymentLogDownloadServesLegitimateFile verifies a normal
|
||||
// log file is served for download.
|
||||
func TestHandleDeploymentLogDownloadServesLegitimateFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
createdApp := createTestApp(t, testCtx, "log-download-app")
|
||||
|
||||
deployment := models.NewDeployment(testCtx.database)
|
||||
deployment.AppID = createdApp.ID
|
||||
deployment.Status = models.DeploymentStatusSuccess
|
||||
require.NoError(t, deployment.Save(context.Background()))
|
||||
|
||||
// Write the log file where the handler will look for it.
|
||||
logPath := testCtx.deploySvc.GetLogFilePath(createdApp, deployment)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0o750))
|
||||
require.NoError(t, os.WriteFile(logPath, []byte("deploy log contents"), 0o600))
|
||||
|
||||
recorder := doLogDownload(t, testCtx, createdApp.ID, deployment.ID)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "deploy log contents")
|
||||
}
|
||||
|
||||
// TestGetLogFilePathHasNoHostname verifies a deployment log is stored
|
||||
// directly under logs/<appname>/, with no hostname directory in between,
|
||||
// so it is still found after the container is recreated with a new
|
||||
// hostname.
|
||||
func TestGetLogFilePathHasNoHostname(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
createdApp := createTestApp(t, testCtx, "log-path-app")
|
||||
|
||||
deployment := models.NewDeployment(testCtx.database)
|
||||
deployment.AppID = createdApp.ID
|
||||
|
||||
logPath := testCtx.deploySvc.GetLogFilePath(createdApp, deployment)
|
||||
|
||||
assert.Equal(t,
|
||||
filepath.Join(testCtx.deploySvc.GetLogDir(), createdApp.Name),
|
||||
filepath.Dir(logPath),
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleDeploymentLogDownloadServesLogFromOldHostnameDir verifies a
|
||||
// log written by an older version, under the hostname of a container that
|
||||
// has since been recreated, can still be downloaded.
|
||||
func TestHandleDeploymentLogDownloadServesLogFromOldHostnameDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
createdApp := createTestApp(t, testCtx, "log-old-hostname-app")
|
||||
|
||||
deployment := models.NewDeployment(testCtx.database)
|
||||
deployment.AppID = createdApp.ID
|
||||
deployment.Status = models.DeploymentStatusSuccess
|
||||
require.NoError(t, deployment.Save(context.Background()))
|
||||
|
||||
logDir := testCtx.deploySvc.GetLogDir()
|
||||
newPath := testCtx.deploySvc.GetLogFilePath(createdApp, deployment)
|
||||
relPath, relErr := filepath.Rel(logDir, newPath)
|
||||
require.NoError(t, relErr)
|
||||
|
||||
oldPath := filepath.Join(logDir, "old-container-hostname", relPath)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(oldPath), 0o750))
|
||||
require.NoError(t, os.WriteFile(oldPath, []byte("old deploy log contents"), 0o600))
|
||||
|
||||
recorder := doLogDownload(t, testCtx, createdApp.ID, deployment.ID)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "old deploy log contents")
|
||||
}
|
||||
|
||||
// TestHandleDeploymentLogDownloadRejectsPathTraversal verifies the
|
||||
// os.Root containment guard. A traversal-shaped app name drives the
|
||||
// resolved log path out of the deploy log directory onto a sentinel
|
||||
// file that really exists. The handler must refuse to serve it (404)
|
||||
// rather than leak its contents. Removing the guard makes this test
|
||||
// fail, which the earlier version — pointed at a non-existent path that
|
||||
// 404s either way — did not.
|
||||
func TestHandleDeploymentLogDownloadRejectsPathTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
createdApp := createTestApp(t, testCtx, "log-traversal-app")
|
||||
|
||||
createdApp.Name = "../.."
|
||||
require.NoError(t, createdApp.Save(context.Background()))
|
||||
|
||||
// The log root must exist so os.OpenRoot succeeds and the rejection
|
||||
// comes from the containment check, not a missing directory.
|
||||
logDir := testCtx.deploySvc.GetLogDir()
|
||||
require.NoError(t, os.MkdirAll(logDir, 0o750))
|
||||
|
||||
deployment := models.NewDeployment(testCtx.database)
|
||||
deployment.AppID = createdApp.ID
|
||||
deployment.Status = models.DeploymentStatusSuccess
|
||||
require.NoError(t, deployment.Save(context.Background()))
|
||||
|
||||
// Where the handler resolves the log path to. The traversal name
|
||||
// makes this land outside logDir; require that it truly escapes so
|
||||
// the test cannot silently stop covering the guard.
|
||||
escapedPath := testCtx.deploySvc.GetLogFilePath(createdApp, deployment)
|
||||
relPath, relErr := filepath.Rel(logDir, escapedPath)
|
||||
require.NoError(t, relErr)
|
||||
require.True(t, strings.HasPrefix(relPath, ".."),
|
||||
"resolved path must escape the log dir, got %q", relPath)
|
||||
|
||||
// Plant a sentinel where the traversal points; a missing guard would
|
||||
// open and serve it.
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(escapedPath), 0o750))
|
||||
|
||||
const sentinel = "SENTINEL-outside-log-dir-must-not-be-served"
|
||||
|
||||
require.NoError(t, os.WriteFile(escapedPath, []byte(sentinel), 0o600))
|
||||
t.Cleanup(func() { _ = os.Remove(escapedPath) })
|
||||
|
||||
recorder := doLogDownload(t, testCtx, createdApp.ID, deployment.ID)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
assert.NotContains(t, recorder.Body.String(), sentinel,
|
||||
"containment guard must not serve a file outside the log dir")
|
||||
}
|
||||
@@ -16,9 +16,7 @@ func TestRenderTemplateBuffersOutput(t *testing.T) {
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
// The setup page is simple and has no DB dependencies
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/setup", nil,
|
||||
)
|
||||
request := httptest.NewRequest(http.MethodGet, "/setup", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleSetupGET()
|
||||
@@ -41,7 +39,7 @@ func TestDashboardRenderTemplateBuffersOutput(t *testing.T) {
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleDashboard()
|
||||
@@ -61,9 +59,7 @@ func TestLoginRenderTemplateBuffersOutput(t *testing.T) {
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/login", nil,
|
||||
)
|
||||
request := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleLoginGET()
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Repo URL validation errors.
|
||||
var (
|
||||
errRepoURLEmpty = errors.New("repository URL must not be empty")
|
||||
errRepoURLScheme = errors.New("file:// URLs are not allowed for security reasons")
|
||||
errRepoURLInvalid = errors.New(
|
||||
"repository URL must use https://, http://, ssh://, git://, " +
|
||||
"or git@host:path format",
|
||||
)
|
||||
errRepoURLNoHost = errors.New("repository URL must include a host")
|
||||
errRepoURLNoPath = errors.New("repository URL must include a path")
|
||||
)
|
||||
|
||||
// scpLikeRepoRe matches SCP-like git URLs: git@host:path
|
||||
// (e.g. git@github.com:user/repo.git). Only the "git" user is allowed,
|
||||
// as that is the standard for SSH deploy keys.
|
||||
var scpLikeRepoRe = regexp.MustCompile(`^git@[a-zA-Z0-9._-]+:.+$`)
|
||||
|
||||
// allowedRepoSchemes lists the URL schemes accepted for repository URLs.
|
||||
//
|
||||
//nolint:gochecknoglobals // package-level constant map parsed once
|
||||
var allowedRepoSchemes = map[string]bool{
|
||||
"https": true,
|
||||
"http": true,
|
||||
"ssh": true,
|
||||
"git": true,
|
||||
}
|
||||
|
||||
// validateRepoURL checks that the given repository URL is valid and
|
||||
// uses an allowed scheme.
|
||||
func validateRepoURL(repoURL string) error {
|
||||
if strings.TrimSpace(repoURL) == "" {
|
||||
return errRepoURLEmpty
|
||||
}
|
||||
|
||||
// Reject path traversal in any URL format
|
||||
if strings.Contains(repoURL, "..") {
|
||||
return errRepoURLInvalid
|
||||
}
|
||||
|
||||
// Check for SCP-like git URLs first (git@host:path)
|
||||
if scpLikeRepoRe.MatchString(repoURL) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject file:// explicitly
|
||||
if strings.HasPrefix(strings.ToLower(repoURL), "file://") {
|
||||
return errRepoURLScheme
|
||||
}
|
||||
|
||||
return validateParsedRepoURL(repoURL)
|
||||
}
|
||||
|
||||
// validateParsedRepoURL validates a standard URL-format repository URL.
|
||||
func validateParsedRepoURL(repoURL string) error {
|
||||
parsed, err := url.Parse(repoURL)
|
||||
if err != nil {
|
||||
return errRepoURLInvalid
|
||||
}
|
||||
|
||||
if !allowedRepoSchemes[strings.ToLower(parsed.Scheme)] {
|
||||
return errRepoURLInvalid
|
||||
}
|
||||
|
||||
if parsed.Host == "" {
|
||||
return errRepoURLNoHost
|
||||
}
|
||||
|
||||
if parsed.Path == "" || parsed.Path == "/" {
|
||||
return errRepoURLNoPath
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/handlers"
|
||||
)
|
||||
|
||||
func TestValidateRepoURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantErr bool
|
||||
}{
|
||||
// Valid URLs
|
||||
{name: "https URL", url: "https://github.com/user/repo.git", wantErr: false},
|
||||
{name: "http URL", url: "http://github.com/user/repo.git", wantErr: false},
|
||||
{name: "ssh URL", url: "ssh://git@github.com/user/repo.git", wantErr: false},
|
||||
{name: "git URL", url: "git://github.com/user/repo.git", wantErr: false},
|
||||
{name: "SCP-like URL", url: "git@github.com:user/repo.git", wantErr: false},
|
||||
{name: "SCP-like with dots", url: "git@git.example.com:org/repo.git", wantErr: false},
|
||||
{name: "https without .git", url: "https://github.com/user/repo", wantErr: false},
|
||||
{
|
||||
name: "https with port",
|
||||
url: "https://git.example.com:8443/user/repo.git",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Invalid URLs
|
||||
{name: "empty string", url: "", wantErr: true},
|
||||
{name: "whitespace only", url: " ", wantErr: true},
|
||||
{name: "file URL", url: "file:///etc/passwd", wantErr: true},
|
||||
{name: "file URL uppercase", url: "FILE:///etc/passwd", wantErr: true},
|
||||
{name: "bare path", url: "/some/local/path", wantErr: true},
|
||||
{name: "relative path", url: "../repo", wantErr: true},
|
||||
{name: "just a word", url: "notaurl", wantErr: true},
|
||||
{name: "ftp URL", url: "ftp://example.com/repo.git", wantErr: true},
|
||||
{name: "no host https", url: "https:///path", wantErr: true},
|
||||
{name: "no path https", url: "https://github.com", wantErr: true},
|
||||
{name: "no path https trailing slash", url: "https://github.com/", wantErr: true},
|
||||
{name: "SCP-like non-git user", url: "root@github.com:user/repo.git", wantErr: true},
|
||||
{
|
||||
name: "SCP-like arbitrary user",
|
||||
url: "admin@github.com:user/repo.git",
|
||||
wantErr: true,
|
||||
},
|
||||
{name: "path traversal SCP", url: "git@github.com:../../etc/passwd", wantErr: true},
|
||||
{
|
||||
name: "path traversal https",
|
||||
url: "https://github.com/user/../../../etc/passwd",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "path traversal in middle",
|
||||
url: "https://github.com/user/repo/../secret",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := handlers.ValidateRepoURLForTest(tc.url)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("ValidateRepoURLForTest(%q) = nil, want error", tc.url)
|
||||
}
|
||||
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("ValidateRepoURLForTest(%q) = %v, want nil", tc.url, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
package handlers //nolint:testpackage // tests unexported parsing functions
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseOptionalFloat64(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("empty string returns invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := parseOptionalFloat64("")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Valid)
|
||||
})
|
||||
|
||||
t.Run("whitespace only returns invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := parseOptionalFloat64(" ")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Valid)
|
||||
})
|
||||
|
||||
t.Run("valid float", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := parseOptionalFloat64("0.5")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Valid)
|
||||
assert.InDelta(t, 0.5, result.Float64, 0.001)
|
||||
})
|
||||
|
||||
t.Run("valid integer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := parseOptionalFloat64("2")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Valid)
|
||||
assert.InDelta(t, 2.0, result.Float64, 0.001)
|
||||
})
|
||||
|
||||
t.Run("negative value rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseOptionalFloat64("-1")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("zero value rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseOptionalFloat64("0")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("non-numeric rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseOptionalFloat64("abc")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseOptionalMemoryBytes(t *testing.T) { //nolint:funlen // table-driven test
|
||||
t.Parallel()
|
||||
|
||||
t.Run("empty string returns invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := parseOptionalMemoryBytes("")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Valid)
|
||||
})
|
||||
|
||||
t.Run("whitespace only returns invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := parseOptionalMemoryBytes(" ")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Valid)
|
||||
})
|
||||
|
||||
t.Run("plain bytes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := parseOptionalMemoryBytes("536870912")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Valid)
|
||||
assert.Equal(t, int64(536870912), result.Int64)
|
||||
})
|
||||
|
||||
t.Run("megabytes suffix", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := parseOptionalMemoryBytes("256m")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Valid)
|
||||
assert.Equal(t, int64(256*1024*1024), result.Int64)
|
||||
})
|
||||
|
||||
t.Run("megabytes suffix uppercase", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := parseOptionalMemoryBytes("256M")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Valid)
|
||||
assert.Equal(t, int64(256*1024*1024), result.Int64)
|
||||
})
|
||||
|
||||
t.Run("gigabytes suffix", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := parseOptionalMemoryBytes("1g")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Valid)
|
||||
assert.Equal(t, int64(1024*1024*1024), result.Int64)
|
||||
})
|
||||
|
||||
t.Run("kilobytes suffix", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := parseOptionalMemoryBytes("512k")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Valid)
|
||||
assert.Equal(t, int64(512*1024), result.Int64)
|
||||
})
|
||||
|
||||
t.Run("fractional gigabytes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := parseOptionalMemoryBytes("1.5g")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Valid)
|
||||
assert.Equal(t, int64(1.5*1024*1024*1024), result.Int64)
|
||||
})
|
||||
|
||||
t.Run("negative value rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseOptionalMemoryBytes("-256m")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("zero value rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseOptionalMemoryBytes("0")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid string rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseOptionalMemoryBytes("abc")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("negative plain bytes rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseOptionalMemoryBytes("-100")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppResourceLimitsRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test that parsing and formatting are consistent
|
||||
tests := []struct {
|
||||
input string
|
||||
expected sql.NullInt64
|
||||
format string
|
||||
}{
|
||||
{"256m", sql.NullInt64{Int64: 256 * 1024 * 1024, Valid: true}, "256m"},
|
||||
{"1g", sql.NullInt64{Int64: 1024 * 1024 * 1024, Valid: true}, "1g"},
|
||||
{"512k", sql.NullInt64{Int64: 512 * 1024, Valid: true}, "512k"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := parseOptionalMemoryBytes(tt.input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ansiEscapePattern matches ANSI escape sequences (CSI, OSC, and
|
||||
// single-character escapes).
|
||||
var ansiEscapePattern = regexp.MustCompile(
|
||||
`(\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]])`,
|
||||
)
|
||||
|
||||
// SanitizeLogs strips ANSI escape sequences and non-printable control characters
|
||||
// from container log output. Newlines (\n), carriage returns (\r), and tabs (\t)
|
||||
// are preserved. This ensures that attacker-controlled container output cannot
|
||||
// inject terminal escape sequences or other dangerous control characters.
|
||||
func SanitizeLogs(input string) string {
|
||||
// Strip ANSI escape sequences
|
||||
result := ansiEscapePattern.ReplaceAllString(input, "")
|
||||
|
||||
// Strip remaining non-printable characters (keep \n, \r, \t)
|
||||
var b strings.Builder
|
||||
b.Grow(len(result))
|
||||
|
||||
for _, r := range result {
|
||||
if r == '\n' || r == '\r' || r == '\t' || r >= ' ' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/handlers"
|
||||
)
|
||||
|
||||
func TestSanitizeLogs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "plain text unchanged",
|
||||
input: "hello world\n",
|
||||
expected: "hello world\n",
|
||||
},
|
||||
{
|
||||
name: "strips ANSI color codes",
|
||||
input: "\x1b[31mERROR\x1b[0m: something failed\n",
|
||||
expected: "ERROR: something failed\n",
|
||||
},
|
||||
{
|
||||
name: "strips OSC sequences",
|
||||
input: "\x1b]0;window title\x07normal text\n",
|
||||
expected: "normal text\n",
|
||||
},
|
||||
{
|
||||
name: "strips null bytes",
|
||||
input: "hello\x00world\n",
|
||||
expected: "helloworld\n",
|
||||
},
|
||||
{
|
||||
name: "strips bell characters",
|
||||
input: "alert\x07here\n",
|
||||
expected: "alerthere\n",
|
||||
},
|
||||
{
|
||||
name: "preserves tabs",
|
||||
input: "field1\tfield2\tfield3\n",
|
||||
expected: "field1\tfield2\tfield3\n",
|
||||
},
|
||||
{
|
||||
name: "preserves carriage returns",
|
||||
input: "line1\r\nline2\r\n",
|
||||
expected: "line1\r\nline2\r\n",
|
||||
},
|
||||
{
|
||||
name: "strips mixed escape sequences",
|
||||
input: "\x1b[32m2024-01-01\x1b[0m \x1b[1mINFO\x1b[0m starting\x00\n",
|
||||
expected: "2024-01-01 INFO starting\n",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "only control characters",
|
||||
input: "\x00\x01\x02\x03",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "cursor movement sequences stripped",
|
||||
input: "\x1b[2J\x1b[H\x1b[3Atext\n",
|
||||
expected: "text\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := handlers.SanitizeLogs(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("SanitizeLogs(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"sneak.berlin/go/upaas/templates"
|
||||
"git.eeqj.de/sneak/upaas/templates"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -55,8 +55,8 @@ func (h *Handlers) renderSetupError(
|
||||
errorMsg string,
|
||||
) {
|
||||
data := h.addGlobals(map[string]any{
|
||||
"Username": username,
|
||||
dataKeyError: errorMsg,
|
||||
"Username": username,
|
||||
"Error": errorMsg,
|
||||
}, request)
|
||||
h.renderTemplate(writer, tmpl, "setup.html", data)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package handlers_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/handlers"
|
||||
"git.eeqj.de/sneak/upaas/internal/handlers"
|
||||
)
|
||||
|
||||
func TestSanitizeTail(t *testing.T) {
|
||||
|
||||
@@ -6,15 +6,13 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"sneak.berlin/go/upaas/internal/service/webhook"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
)
|
||||
|
||||
// maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB).
|
||||
const maxWebhookBodySize = 1 << 20
|
||||
|
||||
// HandleWebhook handles incoming webhooks from Gitea, GitHub, or GitLab.
|
||||
// The webhook source is auto-detected from HTTP headers.
|
||||
// HandleWebhook handles incoming Gitea webhooks.
|
||||
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
secret := chi.URLParam(request, "secret")
|
||||
@@ -52,17 +50,16 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-detect webhook source from headers
|
||||
source := webhook.DetectWebhookSource(request.Header)
|
||||
|
||||
// Extract event type based on detected source
|
||||
eventType := webhook.DetectEventType(request.Header, source)
|
||||
// Get event type from header
|
||||
eventType := request.Header.Get("X-Gitea-Event")
|
||||
if eventType == "" {
|
||||
eventType = "push"
|
||||
}
|
||||
|
||||
// Process webhook
|
||||
webhookErr := h.webhook.HandleWebhook(
|
||||
request.Context(),
|
||||
application,
|
||||
source,
|
||||
eventType,
|
||||
body,
|
||||
)
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"sneak.berlin/go/upaas/templates"
|
||||
)
|
||||
|
||||
// webhookEventsLimit is the number of webhook events to show in history.
|
||||
const webhookEventsLimit = 100
|
||||
|
||||
// HandleAppWebhookEvents returns the webhook event history handler.
|
||||
func (h *Handlers) HandleAppWebhookEvents() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
|
||||
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
||||
if findErr != nil {
|
||||
h.log.Error("failed to find app", "error", findErr)
|
||||
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if application == nil {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
events, eventsErr := application.GetWebhookEvents(
|
||||
request.Context(),
|
||||
webhookEventsLimit,
|
||||
)
|
||||
if eventsErr != nil {
|
||||
h.log.Error("failed to get webhook events",
|
||||
"error", eventsErr,
|
||||
"app", appID,
|
||||
)
|
||||
|
||||
events = []*models.WebhookEvent{}
|
||||
}
|
||||
|
||||
data := h.addGlobals(map[string]any{
|
||||
dataKeyApp: application,
|
||||
"Events": events,
|
||||
}, request)
|
||||
|
||||
h.renderTemplate(writer, tmpl, "webhook_events.html", data)
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
|
||||
"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"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
)
|
||||
|
||||
// Params contains dependencies for Healthcheck.
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
)
|
||||
|
||||
// Params contains dependencies for Logger.
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package logger
|
||||
|
||||
import "log/slog"
|
||||
|
||||
// NewForTest creates a Logger wrapping the given slog.Logger, for use in tests.
|
||||
func NewForTest(log *slog.Logger) *Logger {
|
||||
return &Logger{
|
||||
log: log,
|
||||
level: new(slog.LevelVar),
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
)
|
||||
|
||||
//nolint:gosec // test credentials
|
||||
@@ -24,30 +24,21 @@ func newCORSTestMiddleware(corsOrigins string) *Middleware {
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoCORSHeaders runs a request with the given Origin header through
|
||||
// CORS middleware configured with corsOrigins and asserts that no
|
||||
// Access-Control-Allow-Origin header is set.
|
||||
func assertNoCORSHeaders(t *testing.T, corsOrigins, origin, msg string) {
|
||||
t.Helper()
|
||||
func TestCORS_NoOriginsConfigured_NoCORSHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := newCORSTestMiddleware(corsOrigins)
|
||||
m := newCORSTestMiddleware("")
|
||||
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
req.Header.Set("Origin", origin)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Origin", "https://evil.com")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"), msg)
|
||||
}
|
||||
|
||||
func TestCORS_NoOriginsConfigured_NoCORSHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertNoCORSHeaders(t, "", "https://evil.com",
|
||||
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"),
|
||||
"expected no CORS headers when no origins configured")
|
||||
}
|
||||
|
||||
@@ -59,7 +50,7 @@ func TestCORS_OriginsConfigured_AllowsMatchingOrigin(t *testing.T) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Origin", "https://app.example.com")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -74,6 +65,17 @@ func TestCORS_OriginsConfigured_AllowsMatchingOrigin(t *testing.T) {
|
||||
func TestCORS_OriginsConfigured_RejectsNonMatchingOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertNoCORSHeaders(t, "https://app.example.com", "https://evil.com",
|
||||
m := newCORSTestMiddleware("https://app.example.com")
|
||||
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Origin", "https://evil.com")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"),
|
||||
"expected no CORS headers for non-matching origin")
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package middleware //nolint:testpackage // tests internal CSRF behavior
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
)
|
||||
|
||||
//nolint:gosec // test credentials
|
||||
func newCSRFTestMiddleware(plaintextHTTP bool) *Middleware {
|
||||
return &Middleware{
|
||||
log: slog.Default(),
|
||||
params: &Params{
|
||||
Config: &config.Config{
|
||||
SessionSecret: "test-secret-32-bytes-long-enough",
|
||||
PlaintextHTTP: plaintextHTTP,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// postWithPlainHTTPOrigin drives a tokenless POST carrying a plain-HTTP Origin
|
||||
// through the CSRF middleware and returns the "Forbidden - <reason>" body.
|
||||
// gorilla/csrf checks the Origin before the token, so the reason reveals which
|
||||
// check rejected the request.
|
||||
func postWithPlainHTTPOrigin(t *testing.T, plaintextHTTP bool) string {
|
||||
t.Helper()
|
||||
|
||||
m := newCSRFTestMiddleware(plaintextHTTP)
|
||||
handler := m.CSRF()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodPost, "http://example.com/setup", nil)
|
||||
req.Header.Set("Origin", "http://example.com")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
// Without PlaintextHTTP the origin check assumes https and rejects a browser's
|
||||
// http:// Origin, which is what broke setup over plain HTTP.
|
||||
func TestCSRF_PlaintextDisabled_RejectsPlainHTTPOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Contains(t, postWithPlainHTTPOrigin(t, false), "origin invalid")
|
||||
}
|
||||
|
||||
// With PlaintextHTTP the origin check uses http, so a matching http:// Origin
|
||||
// passes it and the request only fails later for the missing token.
|
||||
func TestCSRF_PlaintextEnabled_AllowsPlainHTTPOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := postWithPlainHTTPOrigin(t, true)
|
||||
assert.NotContains(t, body, "origin invalid")
|
||||
assert.Contains(t, body, "CSRF token not found")
|
||||
}
|
||||
@@ -18,10 +18,10 @@ import (
|
||||
"go.uber.org/fx"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
"sneak.berlin/go/upaas/internal/globals"
|
||||
"sneak.berlin/go/upaas/internal/logger"
|
||||
"sneak.berlin/go/upaas/internal/service/auth"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
||||
)
|
||||
|
||||
// corsMaxAge is the maximum age for CORS preflight responses in seconds.
|
||||
@@ -255,34 +255,12 @@ func (m *Middleware) SessionAuth() func(http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// CSRF returns CSRF protection middleware using gorilla/csrf.
|
||||
//
|
||||
// gorilla/csrf assumes the request scheme is https for its same-origin check
|
||||
// unless the request is marked plaintext. A TLS-terminating reverse proxy
|
||||
// (the default deployment) presents https to the browser, so the default is
|
||||
// correct there. When µPaaS is reached over plain HTTP — directly, or behind a
|
||||
// proxy that does not terminate TLS — set UPAAS_PLAINTEXT_HTTP so the origin
|
||||
// check compares against http:// and setup over plain HTTP works.
|
||||
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||
protect := csrf.Protect(
|
||||
return csrf.Protect(
|
||||
[]byte(m.params.Config.SessionSecret),
|
||||
csrf.Secure(false), // cookie Secure flag; TLS is terminated upstream
|
||||
csrf.Secure(false), // Allow HTTP for development; reverse proxy handles TLS
|
||||
csrf.Path("/"),
|
||||
)
|
||||
|
||||
if !m.params.Config.PlaintextHTTP {
|
||||
return protect
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
protected := protect(next)
|
||||
|
||||
return http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
protected.ServeHTTP(writer, csrf.PlaintextHTTPRequest(request))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// loginRateLimit configures the login rate limiter.
|
||||
@@ -392,9 +370,8 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// APISessionAuth returns middleware that requires session authentication
|
||||
// for API routes. Unlike SessionAuth, it returns JSON 401 responses instead
|
||||
// of redirecting to /login.
|
||||
// APISessionAuth returns middleware that requires session authentication for API routes.
|
||||
// Unlike SessionAuth, it returns JSON 401 responses instead of redirecting to /login.
|
||||
func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
@@ -434,14 +411,8 @@ func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
if setupRequired {
|
||||
path := request.URL.Path
|
||||
|
||||
// Allow access to setup page, health endpoint, static
|
||||
// assets, and API routes even before setup is complete.
|
||||
if path == "/setup" ||
|
||||
path == "/health" ||
|
||||
strings.HasPrefix(path, "/s/") ||
|
||||
strings.HasPrefix(path, "/api/") {
|
||||
// Allow access to setup page
|
||||
if request.URL.Path == "/setup" {
|
||||
next.ServeHTTP(writer, request)
|
||||
|
||||
return
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
)
|
||||
|
||||
func newTestMiddleware(t *testing.T) *Middleware {
|
||||
@@ -30,15 +30,13 @@ func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
|
||||
|
||||
mw := newTestMiddleware(t)
|
||||
|
||||
handler := mw.LoginRateLimit()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
))
|
||||
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// First 5 requests should succeed (burst)
|
||||
for i := range 5 {
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
req.RemoteAddr = "192.168.1.1:12345"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
@@ -46,12 +44,11 @@ func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
|
||||
}
|
||||
|
||||
// 6th request should be rate limited
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
req.RemoteAddr = "192.168.1.1:12345"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusTooManyRequests, rec.Code,
|
||||
"6th request should be rate limited")
|
||||
assert.Equal(t, http.StatusTooManyRequests, rec.Code, "6th request should be rate limited")
|
||||
}
|
||||
|
||||
//nolint:paralleltest // mutates global loginLimiter
|
||||
@@ -60,29 +57,27 @@ func TestLoginRateLimitIsolatesIPs(t *testing.T) {
|
||||
|
||||
mw := newTestMiddleware(t)
|
||||
|
||||
handler := mw.LoginRateLimit()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
))
|
||||
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Exhaust IP1's budget
|
||||
for range 5 {
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
|
||||
req.RemoteAddr = testProxyAddr
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
req.RemoteAddr = "10.0.0.1:1234"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
}
|
||||
|
||||
// IP1 should be blocked
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
|
||||
req.RemoteAddr = testProxyAddr
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
req.RemoteAddr = "10.0.0.1:1234"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
|
||||
|
||||
// IP2 should still work
|
||||
req2 := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
req2.RemoteAddr = "10.0.0.2:1234"
|
||||
rec2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec2, req2)
|
||||
@@ -95,28 +90,25 @@ func TestLoginRateLimitReturns429Body(t *testing.T) {
|
||||
|
||||
mw := newTestMiddleware(t)
|
||||
|
||||
handler := mw.LoginRateLimit()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
))
|
||||
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Exhaust burst
|
||||
for range 5 {
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
req.RemoteAddr = "172.16.0.1:5555"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
req.RemoteAddr = "172.16.0.1:5555"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "Too Many Requests")
|
||||
assert.NotEmpty(t, rec.Header().Get("Retry-After"),
|
||||
"should include Retry-After header")
|
||||
assert.NotEmpty(t, rec.Header().Get("Retry-After"), "should include Retry-After header")
|
||||
}
|
||||
|
||||
func TestIPLimiterEvictsStaleEntries(t *testing.T) {
|
||||
|
||||
@@ -7,16 +7,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Shared test addresses (also used by ratelimit_test.go).
|
||||
const (
|
||||
testProxyAddr = "10.0.0.1:1234"
|
||||
testRealIP = "203.0.113.5"
|
||||
testXFFIP = "198.51.100.1"
|
||||
testPrivateIP = "192.168.1.1"
|
||||
testPublicIP = "93.184.216.34"
|
||||
testPublicDNSIP = "8.8.8.8"
|
||||
)
|
||||
|
||||
func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
|
||||
t.Parallel()
|
||||
|
||||
@@ -30,63 +20,63 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
|
||||
// === Trusted proxy (RFC1918 / loopback) — headers ARE honoured ===
|
||||
{
|
||||
name: "trusted: X-Real-IP from 10.x",
|
||||
remoteAddr: testProxyAddr,
|
||||
xRealIP: testRealIP,
|
||||
remoteAddr: "10.0.0.1:1234",
|
||||
xRealIP: "203.0.113.5",
|
||||
xff: "198.51.100.1, 10.0.0.1",
|
||||
want: testRealIP,
|
||||
want: "203.0.113.5",
|
||||
},
|
||||
{
|
||||
name: "trusted: XFF from 10.x when no X-Real-IP",
|
||||
remoteAddr: testProxyAddr,
|
||||
remoteAddr: "10.0.0.1:1234",
|
||||
xff: "198.51.100.1, 10.0.0.1",
|
||||
want: testXFFIP,
|
||||
want: "198.51.100.1",
|
||||
},
|
||||
{
|
||||
name: "trusted: XFF single IP from 10.x",
|
||||
remoteAddr: testProxyAddr,
|
||||
remoteAddr: "10.0.0.1:1234",
|
||||
xff: "203.0.113.10",
|
||||
want: "203.0.113.10",
|
||||
},
|
||||
{
|
||||
name: "trusted: falls back to RemoteAddr (192.168.x)",
|
||||
remoteAddr: "192.168.1.1:5678",
|
||||
want: testPrivateIP,
|
||||
want: "192.168.1.1",
|
||||
},
|
||||
{
|
||||
name: "trusted: RemoteAddr without port",
|
||||
remoteAddr: testPrivateIP,
|
||||
want: testPrivateIP,
|
||||
remoteAddr: "192.168.1.1",
|
||||
want: "192.168.1.1",
|
||||
},
|
||||
{
|
||||
name: "trusted: X-Real-IP with whitespace from 10.x",
|
||||
remoteAddr: testProxyAddr,
|
||||
remoteAddr: "10.0.0.1:1234",
|
||||
xRealIP: " 203.0.113.5 ",
|
||||
want: testRealIP,
|
||||
want: "203.0.113.5",
|
||||
},
|
||||
{
|
||||
name: "trusted: XFF with whitespace from 10.x",
|
||||
remoteAddr: testProxyAddr,
|
||||
remoteAddr: "10.0.0.1:1234",
|
||||
xff: " 198.51.100.1 , 10.0.0.1",
|
||||
want: testXFFIP,
|
||||
want: "198.51.100.1",
|
||||
},
|
||||
{
|
||||
name: "trusted: empty X-Real-IP falls through to XFF from 10.x",
|
||||
remoteAddr: testProxyAddr,
|
||||
remoteAddr: "10.0.0.1:1234",
|
||||
xRealIP: " ",
|
||||
xff: testXFFIP,
|
||||
want: testXFFIP,
|
||||
xff: "198.51.100.1",
|
||||
want: "198.51.100.1",
|
||||
},
|
||||
{
|
||||
name: "trusted: loopback honours X-Real-IP",
|
||||
remoteAddr: "127.0.0.1:9999",
|
||||
xRealIP: testPublicIP,
|
||||
want: testPublicIP,
|
||||
xRealIP: "93.184.216.34",
|
||||
want: "93.184.216.34",
|
||||
},
|
||||
{
|
||||
name: "trusted: 172.16.x honours XFF",
|
||||
remoteAddr: "172.16.0.1:4321",
|
||||
xff: testPublicDNSIP,
|
||||
want: testPublicDNSIP,
|
||||
xff: "8.8.8.8",
|
||||
want: "8.8.8.8",
|
||||
},
|
||||
|
||||
// === Untrusted proxy (public IP) — headers IGNORED, use RemoteAddr ===
|
||||
@@ -107,17 +97,17 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
|
||||
remoteAddr: "8.8.8.8:443",
|
||||
xRealIP: "1.2.3.4",
|
||||
xff: "5.6.7.8",
|
||||
want: testPublicDNSIP,
|
||||
want: "8.8.8.8",
|
||||
},
|
||||
{
|
||||
name: "untrusted: no headers, public RemoteAddr",
|
||||
remoteAddr: "93.184.216.34:8080",
|
||||
want: testPublicIP,
|
||||
want: "93.184.216.34",
|
||||
},
|
||||
{
|
||||
name: "untrusted: public RemoteAddr without port",
|
||||
remoteAddr: testPublicIP,
|
||||
want: testPublicIP,
|
||||
remoteAddr: "93.184.216.34",
|
||||
want: "93.184.216.34",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -149,9 +139,7 @@ func TestIsTrustedProxy(t *testing.T) {
|
||||
|
||||
trusted := []string{"10.0.0.1", "10.255.255.255", "172.16.0.1", "172.31.255.255",
|
||||
"192.168.0.1", "192.168.255.255", "127.0.0.1", "127.255.255.255", "::1"}
|
||||
untrusted := []string{
|
||||
testPublicDNSIP, "203.0.113.1", "172.32.0.1", "11.0.0.1", "2001:db8::1",
|
||||
}
|
||||
untrusted := []string{"8.8.8.8", "203.0.113.1", "172.32.0.1", "11.0.0.1", "2001:db8::1"}
|
||||
|
||||
for _, addr := range trusted {
|
||||
ip := net.ParseIP(addr)
|
||||
|
||||
+5
-12
@@ -7,15 +7,14 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
)
|
||||
|
||||
// appColumns is the standard column list for app queries.
|
||||
const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
|
||||
previous_image_id, cpu_limit, memory_limit,
|
||||
created_at, updated_at`
|
||||
previous_image_id, created_at, updated_at`
|
||||
|
||||
// AppStatus represents the status of an app.
|
||||
type AppStatus string
|
||||
@@ -48,8 +47,6 @@ type App struct {
|
||||
DockerNetwork sql.NullString
|
||||
NtfyTopic sql.NullString
|
||||
SlackWebhook sql.NullString
|
||||
CPULimit sql.NullFloat64
|
||||
MemoryLimit sql.NullInt64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -145,14 +142,14 @@ func (a *App) insert(ctx context.Context) error {
|
||||
id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
|
||||
previous_image_id, cpu_limit, memory_limit
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
previous_image_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
_, err := a.db.Exec(ctx, query,
|
||||
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
|
||||
a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status,
|
||||
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash,
|
||||
a.PreviousImageID, a.CPULimit, a.MemoryLimit,
|
||||
a.PreviousImageID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -168,7 +165,6 @@ func (a *App) update(ctx context.Context) error {
|
||||
image_id = ?, status = ?,
|
||||
docker_network = ?, ntfy_topic = ?, slack_webhook = ?,
|
||||
previous_image_id = ?,
|
||||
cpu_limit = ?, memory_limit = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`
|
||||
|
||||
@@ -177,7 +173,6 @@ func (a *App) update(ctx context.Context) error {
|
||||
a.ImageID, a.Status,
|
||||
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
|
||||
a.PreviousImageID,
|
||||
a.CPULimit, a.MemoryLimit,
|
||||
a.ID,
|
||||
)
|
||||
|
||||
@@ -193,7 +188,6 @@ func (a *App) scan(row *sql.Row) error {
|
||||
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
|
||||
&a.WebhookSecretHash,
|
||||
&a.PreviousImageID,
|
||||
&a.CPULimit, &a.MemoryLimit,
|
||||
&a.CreatedAt, &a.UpdatedAt,
|
||||
)
|
||||
}
|
||||
@@ -212,7 +206,6 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
|
||||
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
|
||||
&app.WebhookSecretHash,
|
||||
&app.PreviousImageID,
|
||||
&app.CPULimit, &app.MemoryLimit,
|
||||
&app.CreatedAt, &app.UpdatedAt,
|
||||
)
|
||||
if scanErr != nil {
|
||||
|
||||
@@ -5,10 +5,9 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
)
|
||||
|
||||
// DeploymentStatus represents the status of a deployment.
|
||||
@@ -77,11 +76,7 @@ func (d *Deployment) Reload(ctx context.Context) error {
|
||||
return d.scan(row)
|
||||
}
|
||||
|
||||
// maxLogSize is the maximum size of deployment logs stored in the database (1MB).
|
||||
const maxLogSize = 1 << 20
|
||||
|
||||
// AppendLog appends a log line to the deployment logs.
|
||||
// If the total log size exceeds maxLogSize, the oldest lines are truncated.
|
||||
func (d *Deployment) AppendLog(ctx context.Context, line string) error {
|
||||
var currentLogs string
|
||||
|
||||
@@ -89,22 +84,7 @@ func (d *Deployment) AppendLog(ctx context.Context, line string) error {
|
||||
currentLogs = d.Logs.String
|
||||
}
|
||||
|
||||
newLogs := currentLogs + line + "\n"
|
||||
|
||||
if len(newLogs) > maxLogSize {
|
||||
// Keep the most recent logs that fit within the limit.
|
||||
// Find a newline after the truncation point to avoid partial lines.
|
||||
truncateAt := len(newLogs) - maxLogSize
|
||||
idx := strings.Index(newLogs[truncateAt:], "\n")
|
||||
|
||||
if idx >= 0 {
|
||||
newLogs = "[earlier logs truncated]\n" + newLogs[truncateAt+idx+1:]
|
||||
} else {
|
||||
newLogs = "[earlier logs truncated]\n" + newLogs[truncateAt:]
|
||||
}
|
||||
}
|
||||
|
||||
d.Logs = sql.NullString{String: newLogs, Valid: true}
|
||||
d.Logs = sql.NullString{String: currentLogs + line + "\n", Valid: true}
|
||||
|
||||
return d.Save(ctx)
|
||||
}
|
||||
|
||||
+29
-76
@@ -1,3 +1,4 @@
|
||||
//nolint:dupl // Active Record pattern - similar structure to label.go is intentional
|
||||
package models
|
||||
|
||||
import (
|
||||
@@ -6,7 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
)
|
||||
|
||||
// EnvVar represents an environment variable for an app.
|
||||
@@ -93,41 +94,6 @@ func FindEnvVar(
|
||||
return envVar, nil
|
||||
}
|
||||
|
||||
// findAllByAppID loads all rows for an app, scanning each row into a
|
||||
// new model created by newFn. entity names the model in error messages.
|
||||
func findAllByAppID[T interface{ scanDest() []any }](
|
||||
ctx context.Context,
|
||||
db *database.Database,
|
||||
query, appID, entity string,
|
||||
newFn func(*database.Database) T,
|
||||
) ([]T, error) {
|
||||
rows, err := db.Query(ctx, query, appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying %s by app: %w", entity, err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var items []T
|
||||
|
||||
for rows.Next() {
|
||||
item := newFn(db)
|
||||
|
||||
scanErr := rows.Scan(item.scanDest()...)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (e *EnvVar) scanDest() []any {
|
||||
return []any{&e.ID, &e.AppID, &e.Key, &e.Value}
|
||||
}
|
||||
|
||||
// FindEnvVarsByAppID finds all env vars for an app.
|
||||
func FindEnvVarsByAppID(
|
||||
ctx context.Context,
|
||||
@@ -138,51 +104,38 @@ func FindEnvVarsByAppID(
|
||||
SELECT id, app_id, key, value FROM app_env_vars
|
||||
WHERE app_id = ? ORDER BY key`
|
||||
|
||||
return findAllByAppID(ctx, db, query, appID, "env vars", NewEnvVar)
|
||||
rows, err := db.Query(ctx, query, appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying env vars by app: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var envVars []*EnvVar
|
||||
|
||||
for rows.Next() {
|
||||
envVar := NewEnvVar(db)
|
||||
|
||||
scanErr := rows.Scan(
|
||||
&envVar.ID, &envVar.AppID, &envVar.Key, &envVar.Value,
|
||||
)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
|
||||
envVars = append(envVars, envVar)
|
||||
}
|
||||
|
||||
return envVars, rows.Err()
|
||||
}
|
||||
|
||||
// EnvVarPair is a key-value pair for bulk env var operations.
|
||||
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(
|
||||
// DeleteEnvVarsByAppID deletes all env vars for an app.
|
||||
func DeleteEnvVarsByAppID(
|
||||
ctx context.Context,
|
||||
db *database.Database,
|
||||
appID string,
|
||||
pairs []EnvVarPair,
|
||||
) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("beginning transaction: %w", err)
|
||||
}
|
||||
_, err := db.Exec(ctx, "DELETE FROM app_env_vars WHERE app_id = ?", appID)
|
||||
|
||||
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
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:dupl // Active Record pattern - similar structure to env_var.go is intentional
|
||||
package models
|
||||
|
||||
import (
|
||||
@@ -6,7 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
)
|
||||
|
||||
// Label represents a Docker label for an app container.
|
||||
@@ -93,10 +94,6 @@ func FindLabel(
|
||||
return label, nil
|
||||
}
|
||||
|
||||
func (l *Label) scanDest() []any {
|
||||
return []any{&l.ID, &l.AppID, &l.Key, &l.Value}
|
||||
}
|
||||
|
||||
// FindLabelsByAppID finds all labels for an app.
|
||||
func FindLabelsByAppID(
|
||||
ctx context.Context,
|
||||
@@ -107,7 +104,27 @@ func FindLabelsByAppID(
|
||||
SELECT id, app_id, key, value FROM app_labels
|
||||
WHERE app_id = ? ORDER BY key`
|
||||
|
||||
return findAllByAppID(ctx, db, query, appID, "labels", NewLabel)
|
||||
rows, err := db.Query(ctx, query, appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying labels by app: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var labels []*Label
|
||||
|
||||
for rows.Next() {
|
||||
label := NewLabel(db)
|
||||
|
||||
scanErr := rows.Scan(&label.ID, &label.AppID, &label.Key, &label.Value)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
|
||||
labels = append(labels, label)
|
||||
}
|
||||
|
||||
return labels, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteLabelsByAppID deletes all labels for an app.
|
||||
|
||||
+46
-154
@@ -10,11 +10,11 @@ import (
|
||||
"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/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
)
|
||||
|
||||
// Test constants to satisfy goconst linter.
|
||||
@@ -317,54 +317,33 @@ func TestAllApps(t *testing.T) {
|
||||
|
||||
// EnvVar Tests.
|
||||
|
||||
// testKVCreateAndFind exercises the create-and-find round trip shared
|
||||
// by key-value models (env vars, labels).
|
||||
func testKVCreateAndFind[T any](
|
||||
t *testing.T,
|
||||
wantKey string,
|
||||
create func(db *database.Database, appID string) (int64, error),
|
||||
find func(context.Context, *database.Database, string) ([]T, error),
|
||||
keyOf func(T) string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
testDB, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create app first.
|
||||
app := createTestApp(t, testDB)
|
||||
|
||||
id, err := create(testDB, app.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, id)
|
||||
|
||||
found, err := find(context.Background(), testDB, app.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, found, 1)
|
||||
assert.Equal(t, wantKey, keyOf(found[0]))
|
||||
}
|
||||
|
||||
func saveTestEnvVar(db *database.Database, appID string) (int64, error) {
|
||||
envVar := models.NewEnvVar(db)
|
||||
envVar.AppID = appID
|
||||
envVar.Key = "DATABASE_URL"
|
||||
envVar.Value = "postgres://localhost/db"
|
||||
|
||||
err := envVar.Save(context.Background())
|
||||
|
||||
return envVar.ID, err
|
||||
}
|
||||
|
||||
func TestEnvVarCRUD(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("creates and finds env vars", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testKVCreateAndFind(t, "DATABASE_URL", saveTestEnvVar,
|
||||
models.FindEnvVarsByAppID,
|
||||
func(e *models.EnvVar) string { return e.Key },
|
||||
testDB, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create app first.
|
||||
app := createTestApp(t, testDB)
|
||||
|
||||
envVar := models.NewEnvVar(testDB)
|
||||
envVar.AppID = app.ID
|
||||
envVar.Key = "DATABASE_URL"
|
||||
envVar.Value = "postgres://localhost/db"
|
||||
|
||||
err := envVar.Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, envVar.ID)
|
||||
|
||||
envVars, err := models.FindEnvVarsByAppID(
|
||||
context.Background(), testDB, app.ID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, envVars, 1)
|
||||
assert.Equal(t, "DATABASE_URL", envVars[0].Key)
|
||||
})
|
||||
|
||||
t.Run("deletes env var", func(t *testing.T) {
|
||||
@@ -396,27 +375,32 @@ func TestEnvVarCRUD(t *testing.T) {
|
||||
|
||||
// Label Tests.
|
||||
|
||||
func saveTestLabel(db *database.Database, appID string) (int64, error) {
|
||||
label := models.NewLabel(db)
|
||||
label.AppID = appID
|
||||
label.Key = "traefik.enable"
|
||||
label.Value = "true"
|
||||
|
||||
err := label.Save(context.Background())
|
||||
|
||||
return label.ID, err
|
||||
}
|
||||
|
||||
func TestLabelCRUD(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("creates and finds labels", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testKVCreateAndFind(t, "traefik.enable", saveTestLabel,
|
||||
models.FindLabelsByAppID,
|
||||
func(l *models.Label) string { return l.Key },
|
||||
testDB, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
app := createTestApp(t, testDB)
|
||||
|
||||
label := models.NewLabel(testDB)
|
||||
label.AppID = app.ID
|
||||
label.Key = "traefik.enable"
|
||||
label.Value = "true"
|
||||
|
||||
err := label.Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, label.ID)
|
||||
|
||||
labels, err := models.FindLabelsByAppID(
|
||||
context.Background(), testDB, app.ID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, labels, 1)
|
||||
assert.Equal(t, "traefik.enable", labels[0].Key)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -585,9 +569,7 @@ func TestDeploymentFindByAppID(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
deployments, err := models.FindDeploymentsByAppID(
|
||||
context.Background(), testDB, app.ID, 3,
|
||||
)
|
||||
deployments, err := models.FindDeploymentsByAppID(context.Background(), testDB, app.ID, 3)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, deployments, 3)
|
||||
}
|
||||
@@ -724,6 +706,7 @@ func TestAppGetWebhookEvents(t *testing.T) {
|
||||
|
||||
// Cascade Delete Tests.
|
||||
|
||||
//nolint:funlen // Test function with many assertions - acceptable for integration tests
|
||||
func TestCascadeDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -798,97 +781,6 @@ func TestCascadeDelete(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// Resource Limits Tests.
|
||||
|
||||
//nolint:funlen // integration test with multiple subtests
|
||||
func TestAppResourceLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("saves and loads CPU limit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testDB, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
app := createTestApp(t, testDB)
|
||||
|
||||
app.CPULimit = sql.NullFloat64{Float64: 0.5, Valid: true}
|
||||
|
||||
err := app.Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := models.FindApp(context.Background(), testDB, app.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, found)
|
||||
assert.True(t, found.CPULimit.Valid)
|
||||
assert.InDelta(t, 0.5, found.CPULimit.Float64, 0.001)
|
||||
})
|
||||
|
||||
t.Run("saves and loads memory limit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testDB, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
app := createTestApp(t, testDB)
|
||||
|
||||
app.MemoryLimit = sql.NullInt64{Int64: 536870912, Valid: true} // 512m
|
||||
|
||||
err := app.Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := models.FindApp(context.Background(), testDB, app.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, found)
|
||||
assert.True(t, found.MemoryLimit.Valid)
|
||||
assert.Equal(t, int64(536870912), found.MemoryLimit.Int64)
|
||||
})
|
||||
|
||||
t.Run("null limits by default", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testDB, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
app := createTestApp(t, testDB)
|
||||
|
||||
found, err := models.FindApp(context.Background(), testDB, app.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, found)
|
||||
assert.False(t, found.CPULimit.Valid)
|
||||
assert.False(t, found.MemoryLimit.Valid)
|
||||
})
|
||||
|
||||
t.Run("clears limits when set to null", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testDB, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
app := createTestApp(t, testDB)
|
||||
|
||||
// Set limits
|
||||
app.CPULimit = sql.NullFloat64{Float64: 1.0, Valid: true}
|
||||
app.MemoryLimit = sql.NullInt64{Int64: 1073741824, Valid: true} // 1g
|
||||
|
||||
err := app.Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Clear limits
|
||||
app.CPULimit = sql.NullFloat64{}
|
||||
app.MemoryLimit = sql.NullInt64{}
|
||||
|
||||
err = app.Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := models.FindApp(context.Background(), testDB, app.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, found)
|
||||
assert.False(t, found.CPULimit.Valid)
|
||||
assert.False(t, found.MemoryLimit.Valid)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to create a test app.
|
||||
func createTestApp(t *testing.T, testDB *database.Database) *models.App {
|
||||
t.Helper()
|
||||
|
||||
+25
-8
@@ -6,7 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
)
|
||||
|
||||
// PortProtocol represents the protocol for a port mapping.
|
||||
@@ -112,12 +112,6 @@ func FindPort(
|
||||
return port, nil
|
||||
}
|
||||
|
||||
func (p *Port) scanDest() []any {
|
||||
return []any{
|
||||
&p.ID, &p.AppID, &p.HostPort, &p.ContainerPort, &p.Protocol,
|
||||
}
|
||||
}
|
||||
|
||||
// FindPortsByAppID finds all ports for an app.
|
||||
func FindPortsByAppID(
|
||||
ctx context.Context,
|
||||
@@ -128,7 +122,30 @@ func FindPortsByAppID(
|
||||
SELECT id, app_id, host_port, container_port, protocol
|
||||
FROM app_ports WHERE app_id = ? ORDER BY host_port`
|
||||
|
||||
return findAllByAppID(ctx, db, query, appID, "ports", NewPort)
|
||||
rows, err := db.Query(ctx, query, appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying ports by app: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var ports []*Port
|
||||
|
||||
for rows.Next() {
|
||||
port := NewPort(db)
|
||||
|
||||
scanErr := rows.Scan(
|
||||
&port.ID, &port.AppID, &port.HostPort,
|
||||
&port.ContainerPort, &port.Protocol,
|
||||
)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
|
||||
ports = append(ports, port)
|
||||
}
|
||||
|
||||
return ports, rows.Err()
|
||||
}
|
||||
|
||||
// DeletePortsByAppID deletes all ports for an app.
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
)
|
||||
|
||||
// User represents a user in the system.
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
)
|
||||
|
||||
// Volume represents a volume mount for an app container.
|
||||
@@ -103,12 +103,6 @@ func FindVolume(
|
||||
return vol, nil
|
||||
}
|
||||
|
||||
func (v *Volume) scanDest() []any {
|
||||
return []any{
|
||||
&v.ID, &v.AppID, &v.HostPath, &v.ContainerPath, &v.ReadOnly,
|
||||
}
|
||||
}
|
||||
|
||||
// FindVolumesByAppID finds all volumes for an app.
|
||||
func FindVolumesByAppID(
|
||||
ctx context.Context,
|
||||
@@ -119,7 +113,30 @@ func FindVolumesByAppID(
|
||||
SELECT id, app_id, host_path, container_path, readonly
|
||||
FROM app_volumes WHERE app_id = ? ORDER BY container_path`
|
||||
|
||||
return findAllByAppID(ctx, db, query, appID, "volumes", NewVolume)
|
||||
rows, err := db.Query(ctx, query, appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying volumes by app: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var volumes []*Volume
|
||||
|
||||
for rows.Next() {
|
||||
vol := NewVolume(db)
|
||||
|
||||
scanErr := rows.Scan(
|
||||
&vol.ID, &vol.AppID, &vol.HostPath,
|
||||
&vol.ContainerPath, &vol.ReadOnly,
|
||||
)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
|
||||
volumes = append(volumes, vol)
|
||||
}
|
||||
|
||||
return volumes, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteVolumesByAppID deletes all volumes for an app.
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
)
|
||||
|
||||
// WebhookEvent represents a received webhook event.
|
||||
@@ -52,20 +52,6 @@ func (w *WebhookEvent) Reload(ctx context.Context) error {
|
||||
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 {
|
||||
query := `
|
||||
INSERT INTO webhook_events (
|
||||
|
||||
+10
-12
@@ -8,7 +8,7 @@ import (
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"sneak.berlin/go/upaas/static"
|
||||
"git.eeqj.de/sneak/upaas/static"
|
||||
)
|
||||
|
||||
// requestTimeout is the maximum duration for handling a request.
|
||||
@@ -70,15 +70,8 @@ func (s *Server) SetupRoutes() {
|
||||
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
|
||||
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
|
||||
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
|
||||
r.Get("/apps/{id}/webhooks", s.handlers.HandleAppWebhookEvents())
|
||||
r.Get(
|
||||
"/apps/{id}/deployments/{deploymentID}/logs",
|
||||
s.handlers.HandleDeploymentLogsAPI(),
|
||||
)
|
||||
r.Get(
|
||||
"/apps/{id}/deployments/{deploymentID}/download",
|
||||
s.handlers.HandleDeploymentLogDownload(),
|
||||
)
|
||||
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
|
||||
r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
|
||||
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
|
||||
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
|
||||
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
|
||||
@@ -88,8 +81,10 @@ func (s *Server) SetupRoutes() {
|
||||
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
|
||||
r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
|
||||
|
||||
// Environment variables (monolithic bulk save)
|
||||
r.Post("/apps/{id}/env", s.handlers.HandleEnvVarSave())
|
||||
// Environment variables
|
||||
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
|
||||
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
|
||||
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
|
||||
|
||||
// Labels
|
||||
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
|
||||
@@ -119,7 +114,10 @@ func (s *Server) SetupRoutes() {
|
||||
r.Get("/whoami", s.handlers.HandleAPIWhoAmI())
|
||||
|
||||
r.Get("/apps", s.handlers.HandleAPIListApps())
|
||||
r.Post("/apps", s.handlers.HandleAPICreateApp())
|
||||
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp())
|
||||
r.Delete("/apps/{id}", s.handlers.HandleAPIDeleteApp())
|
||||
r.Post("/apps/{id}/deploy", s.handlers.HandleAPITriggerDeploy())
|
||||
r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
"sneak.berlin/go/upaas/internal/globals"
|
||||
"sneak.berlin/go/upaas/internal/handlers"
|
||||
"sneak.berlin/go/upaas/internal/logger"
|
||||
"sneak.berlin/go/upaas/internal/middleware"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/handlers"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/middleware"
|
||||
)
|
||||
|
||||
// Params contains dependencies for Server.
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"sneak.berlin/go/upaas/internal/logger"
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"sneak.berlin/go/upaas/internal/ssh"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/ssh"
|
||||
)
|
||||
|
||||
// ServiceParams contains dependencies for Service.
|
||||
|
||||
@@ -8,20 +8,14 @@ import (
|
||||
"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/models"
|
||||
"sneak.berlin/go/upaas/internal/service/app"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/app"
|
||||
)
|
||||
|
||||
// testRepoURL is the default repository URL used across tests.
|
||||
const testRepoURL = "git@example.com:user/repo.git"
|
||||
|
||||
// giteaRepoURL is the gitea repository URL used across tests.
|
||||
const giteaRepoURL = "git@gitea.example.com:user/repo.git"
|
||||
|
||||
func setupTestService(t *testing.T) (*app.Service, func()) {
|
||||
t.Helper()
|
||||
|
||||
@@ -64,8 +58,7 @@ func setupTestService(t *testing.T) (*app.Service, func()) {
|
||||
}
|
||||
|
||||
// deleteItemTestHelper is a generic helper for testing delete operations.
|
||||
// It creates an app, adds an item, verifies it exists, deletes it, and
|
||||
// verifies it's gone.
|
||||
// It creates an app, adds an item, verifies it exists, deletes it, and verifies it's gone.
|
||||
func deleteItemTestHelper(
|
||||
t *testing.T,
|
||||
appName string,
|
||||
@@ -80,7 +73,7 @@ func deleteItemTestHelper(
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: appName,
|
||||
RepoURL: testRepoURL,
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -99,35 +92,6 @@ func deleteItemTestHelper(
|
||||
assert.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
// runDeleteItemTest adapts typed list/delete callbacks so delete tests for
|
||||
// different item types can share deleteItemTestHelper.
|
||||
func runDeleteItemTest[T any](
|
||||
t *testing.T,
|
||||
appName string,
|
||||
addItem func(ctx context.Context, svc *app.Service, appID string) error,
|
||||
listItems func(ctx context.Context, application *models.App) ([]T, error),
|
||||
deleteFirst func(ctx context.Context, svc *app.Service, item T) error,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
deleteItemTestHelper(t, appName,
|
||||
addItem,
|
||||
func(ctx context.Context, application *models.App) (int, error) {
|
||||
items, err := listItems(ctx, application)
|
||||
|
||||
return len(items), err
|
||||
},
|
||||
func(ctx context.Context, svc *app.Service, application *models.App) error {
|
||||
items, err := listItems(ctx, application)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return deleteFirst(ctx, svc, items[0])
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestCreateAppWithGeneratedKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -136,7 +100,7 @@ func TestCreateAppWithGeneratedKeys(t *testing.T) {
|
||||
|
||||
input := app.CreateAppInput{
|
||||
Name: "test-app",
|
||||
RepoURL: giteaRepoURL,
|
||||
RepoURL: "git@gitea.example.com:user/repo.git",
|
||||
Branch: "main",
|
||||
DockerfilePath: "Dockerfile",
|
||||
}
|
||||
@@ -146,7 +110,7 @@ func TestCreateAppWithGeneratedKeys(t *testing.T) {
|
||||
require.NotNil(t, createdApp)
|
||||
|
||||
assert.Equal(t, "test-app", createdApp.Name)
|
||||
assert.Equal(t, giteaRepoURL, createdApp.RepoURL)
|
||||
assert.Equal(t, "git@gitea.example.com:user/repo.git", createdApp.RepoURL)
|
||||
assert.Equal(t, "main", createdApp.Branch)
|
||||
assert.Equal(t, "Dockerfile", createdApp.DockerfilePath)
|
||||
assert.NotEmpty(t, createdApp.ID)
|
||||
@@ -166,7 +130,7 @@ func TestCreateAppDefaults(t *testing.T) {
|
||||
|
||||
input := app.CreateAppInput{
|
||||
Name: "test-app-defaults",
|
||||
RepoURL: giteaRepoURL,
|
||||
RepoURL: "git@gitea.example.com:user/repo.git",
|
||||
}
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), input)
|
||||
@@ -184,7 +148,7 @@ func TestCreateAppOptionalFields(t *testing.T) {
|
||||
|
||||
input := app.CreateAppInput{
|
||||
Name: "test-app-full",
|
||||
RepoURL: giteaRepoURL,
|
||||
RepoURL: "git@gitea.example.com:user/repo.git",
|
||||
Branch: "develop",
|
||||
DockerNetwork: "my-network",
|
||||
NtfyTopic: "https://ntfy.sh/my-topic",
|
||||
@@ -212,7 +176,7 @@ func TestUpdateApp(testingT *testing.T) {
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "original-name",
|
||||
RepoURL: testRepoURL,
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -244,7 +208,7 @@ func TestUpdateApp(testingT *testing.T) {
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "test-clear",
|
||||
RepoURL: testRepoURL,
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
NtfyTopic: "https://ntfy.sh/topic",
|
||||
SlackWebhook: "https://slack.com/hook",
|
||||
})
|
||||
@@ -252,7 +216,7 @@ func TestUpdateApp(testingT *testing.T) {
|
||||
|
||||
err = svc.UpdateApp(context.Background(), createdApp, app.UpdateAppInput{
|
||||
Name: "test-clear",
|
||||
RepoURL: testRepoURL,
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
Branch: "main",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -276,7 +240,7 @@ func TestDeleteApp(testingT *testing.T) {
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "to-delete",
|
||||
RepoURL: testRepoURL,
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -300,7 +264,7 @@ func TestGetApp(testingT *testing.T) {
|
||||
|
||||
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "findable-app",
|
||||
RepoURL: testRepoURL,
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -335,7 +299,7 @@ func TestGetAppByWebhookSecret(testingT *testing.T) {
|
||||
|
||||
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "webhook-app",
|
||||
RepoURL: testRepoURL,
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -414,7 +378,7 @@ func TestEnvVarsAddAndRetrieve(t *testing.T) {
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "env-test",
|
||||
RepoURL: testRepoURL,
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -447,33 +411,29 @@ func TestEnvVarsAddAndRetrieve(t *testing.T) {
|
||||
assert.Equal(t, "secret123", keys["API_KEY"])
|
||||
}
|
||||
|
||||
// addDeletableEnvVar seeds the env var removed in the delete test.
|
||||
func addDeletableEnvVar(
|
||||
ctx context.Context, svc *app.Service, appID string,
|
||||
) error {
|
||||
return svc.AddEnvVar(ctx, appID, "TO_DELETE", "value")
|
||||
}
|
||||
|
||||
func TestEnvVarsDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runDeleteItemTest(t, "env-delete-test", addDeletableEnvVar,
|
||||
func(ctx context.Context, application *models.App) ([]*models.EnvVar, error) {
|
||||
return application.GetEnvVars(ctx)
|
||||
deleteItemTestHelper(t, "env-delete-test",
|
||||
func(ctx context.Context, svc *app.Service, appID string) error {
|
||||
return svc.AddEnvVar(ctx, appID, "TO_DELETE", "value")
|
||||
},
|
||||
func(ctx context.Context, svc *app.Service, item *models.EnvVar) error {
|
||||
return svc.DeleteEnvVar(ctx, item.ID)
|
||||
func(ctx context.Context, application *models.App) (int, error) {
|
||||
envVars, err := application.GetEnvVars(ctx)
|
||||
|
||||
return len(envVars), err
|
||||
},
|
||||
func(ctx context.Context, svc *app.Service, application *models.App) error {
|
||||
envVars, err := application.GetEnvVars(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.DeleteEnvVar(ctx, envVars[0].ID)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// addDeletableLabel seeds the label removed in the delete test.
|
||||
func addDeletableLabel(
|
||||
ctx context.Context, svc *app.Service, appID string,
|
||||
) error {
|
||||
return svc.AddLabel(ctx, appID, "to.delete", "value")
|
||||
}
|
||||
|
||||
func TestLabels(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
@@ -485,7 +445,7 @@ func TestLabels(testingT *testing.T) {
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "label-test",
|
||||
RepoURL: testRepoURL,
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -508,12 +468,22 @@ func TestLabels(testingT *testing.T) {
|
||||
testingT.Run("deletes label", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runDeleteItemTest(t, "label-delete-test", addDeletableLabel,
|
||||
func(ctx context.Context, application *models.App) ([]*models.Label, error) {
|
||||
return application.GetLabels(ctx)
|
||||
deleteItemTestHelper(t, "label-delete-test",
|
||||
func(ctx context.Context, svc *app.Service, appID string) error {
|
||||
return svc.AddLabel(ctx, appID, "to.delete", "value")
|
||||
},
|
||||
func(ctx context.Context, svc *app.Service, item *models.Label) error {
|
||||
return svc.DeleteLabel(ctx, item.ID)
|
||||
func(ctx context.Context, application *models.App) (int, error) {
|
||||
labels, err := application.GetLabels(ctx)
|
||||
|
||||
return len(labels), err
|
||||
},
|
||||
func(ctx context.Context, svc *app.Service, application *models.App) error {
|
||||
labels, err := application.GetLabels(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.DeleteLabel(ctx, labels[0].ID)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -527,7 +497,7 @@ func TestVolumesAddAndRetrieve(t *testing.T) {
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "volume-test",
|
||||
RepoURL: testRepoURL,
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -577,7 +547,7 @@ func TestVolumesDelete(t *testing.T) {
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "volume-delete-test",
|
||||
RepoURL: testRepoURL,
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -613,7 +583,7 @@ func TestUpdateAppStatus(testingT *testing.T) {
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "status-test",
|
||||
RepoURL: testRepoURL,
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, models.AppStatusPending, createdApp.Status)
|
||||
|
||||
@@ -15,10 +15,10 @@ import (
|
||||
"go.uber.org/fx"
|
||||
"golang.org/x/crypto/argon2"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"sneak.berlin/go/upaas/internal/logger"
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"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/service/auth"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
||||
)
|
||||
|
||||
func setupTestService(t *testing.T) (*auth.Service, func()) {
|
||||
@@ -121,7 +121,7 @@ func getSessionCookie(t *testing.T, svc *auth.Service) *http.Cookie {
|
||||
require.NoError(t, err)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
err = svc.CreateSession(recorder, request, user)
|
||||
require.NoError(t, err)
|
||||
@@ -144,11 +144,7 @@ func TestSessionCookieSecureFlag(testingT *testing.T) {
|
||||
svc := setupAuthService(t, false)
|
||||
cookie := getSessionCookie(t, svc)
|
||||
require.NotNil(t, cookie, "session cookie should exist")
|
||||
assert.True(
|
||||
t,
|
||||
cookie.Secure,
|
||||
"session cookie should have Secure flag in production mode",
|
||||
)
|
||||
assert.True(t, cookie.Secure, "session cookie should have Secure flag in production mode")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -328,12 +324,7 @@ func TestCreateUserRaceCondition(testingT *testing.T) {
|
||||
}
|
||||
|
||||
assert.Equal(t, 1, successes, "exactly one goroutine should succeed")
|
||||
assert.Equal(
|
||||
t,
|
||||
goroutines-1,
|
||||
failures,
|
||||
"all other goroutines should fail with ErrUserExists",
|
||||
)
|
||||
assert.Equal(t, goroutines-1, failures, "all other goroutines should fail with ErrUserExists")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -389,9 +380,7 @@ func TestDestroySessionMaxAge(testingT *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
)
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
err := svc.DestroySession(recorder, request)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -9,22 +9,20 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"sneak.berlin/go/upaas/internal/docker"
|
||||
"sneak.berlin/go/upaas/internal/logger"
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"sneak.berlin/go/upaas/internal/service/notify"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/docker"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/notify"
|
||||
)
|
||||
|
||||
// Time constants.
|
||||
@@ -68,8 +66,7 @@ const logFilePermissions = 0o640
|
||||
// logTimestampFormat is the format for log file timestamps.
|
||||
const logTimestampFormat = "20060102T150405Z"
|
||||
|
||||
// logFileShortSHALength is the number of characters to use for commit SHA
|
||||
// in log filenames.
|
||||
// logFileShortSHALength is the number of characters to use for commit SHA in log filenames.
|
||||
const logFileShortSHALength = 12
|
||||
|
||||
// dockerLogMessage represents a Docker build log message.
|
||||
@@ -90,10 +87,7 @@ type deploymentLogWriter struct {
|
||||
flushCtx context.Context //nolint:containedctx // needed for async flush goroutine
|
||||
}
|
||||
|
||||
func newDeploymentLogWriter(
|
||||
ctx context.Context,
|
||||
deployment *models.Deployment,
|
||||
) *deploymentLogWriter {
|
||||
func newDeploymentLogWriter(ctx context.Context, deployment *models.Deployment) *deploymentLogWriter {
|
||||
w := &deploymentLogWriter{
|
||||
deployment: deployment,
|
||||
done: make(chan struct{}),
|
||||
@@ -257,20 +251,18 @@ func New(lc fx.Lifecycle, params ServiceParams) (*Service, error) {
|
||||
}
|
||||
|
||||
// GetBuildDir returns the build directory path for an app.
|
||||
func (svc *Service) GetBuildDir(appName string) string {
|
||||
return filepath.Join(svc.config.DataDir, "builds", appName)
|
||||
func (svc *Service) GetBuildDir(appID string) string {
|
||||
return filepath.Join(svc.config.DataDir, "builds", appID)
|
||||
}
|
||||
|
||||
// GetLogFilePath returns the path to the log file for a deployment.
|
||||
// Returns empty string if the path cannot be determined.
|
||||
//
|
||||
// The path must not depend on the container's hostname: Docker assigns a
|
||||
// new one whenever the container is recreated, and older logs would then
|
||||
// no longer be found.
|
||||
func (svc *Service) GetLogFilePath(
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
) string {
|
||||
func (svc *Service) GetLogFilePath(app *models.App, deployment *models.Deployment) string {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
hostname = "unknown"
|
||||
}
|
||||
|
||||
// Get commit SHA
|
||||
sha := ""
|
||||
if deployment.CommitSHA.Valid && deployment.CommitSHA.String != "" {
|
||||
@@ -283,8 +275,7 @@ func (svc *Service) GetLogFilePath(
|
||||
// Use started_at timestamp
|
||||
timestamp := deployment.StartedAt.UTC().Format(logTimestampFormat)
|
||||
|
||||
// Build filename: appname_sha_timestamp.log.txt
|
||||
// (or appname_timestamp.log.txt if no SHA)
|
||||
// Build filename: appname_sha_timestamp.log.txt (or appname_timestamp.log.txt if no SHA)
|
||||
var filename string
|
||||
if sha != "" {
|
||||
filename = fmt.Sprintf("%s_%s_%s.log.txt", app.Name, sha, timestamp)
|
||||
@@ -292,13 +283,7 @@ func (svc *Service) GetLogFilePath(
|
||||
filename = fmt.Sprintf("%s_%s.log.txt", app.Name, timestamp)
|
||||
}
|
||||
|
||||
return filepath.Join(svc.config.DataDir, "logs", app.Name, filename)
|
||||
}
|
||||
|
||||
// GetLogDir returns the root directory under which all deployment log
|
||||
// files live. Paths returned by GetLogFilePath are always inside it.
|
||||
func (svc *Service) GetLogDir() string {
|
||||
return filepath.Join(svc.config.DataDir, "logs")
|
||||
return filepath.Join(svc.config.DataDir, "logs", hostname, app.Name, filename)
|
||||
}
|
||||
|
||||
// HasActiveDeploy returns true if there is an active deployment for the given app.
|
||||
@@ -323,8 +308,7 @@ func (svc *Service) CancelDeploy(appID string) bool {
|
||||
|
||||
// Deploy deploys an app. If cancelExisting is true (e.g. webhook-triggered),
|
||||
// any in-progress deploy for the same app will be cancelled before starting.
|
||||
// If cancelExisting is false and a deploy is in progress,
|
||||
// ErrDeploymentInProgress is returned.
|
||||
// If cancelExisting is false and a deploy is in progress, ErrDeploymentInProgress is returned.
|
||||
func (svc *Service) Deploy(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
@@ -358,8 +342,7 @@ func (svc *Service) Deploy(
|
||||
// Fetch webhook event and create deployment record
|
||||
webhookEvent := svc.fetchWebhookEvent(deployCtx, webhookEventID)
|
||||
|
||||
// Use a background context for DB operations that must complete
|
||||
// regardless of cancellation
|
||||
// Use a background context for DB operations that must complete regardless of cancellation
|
||||
bgCtx := context.WithoutCancel(deployCtx)
|
||||
|
||||
deployment, err := svc.createDeploymentRecord(bgCtx, app, webhookEventID, webhookEvent)
|
||||
@@ -418,10 +401,7 @@ func (svc *Service) createRollbackDeployment(
|
||||
return nil, fmt.Errorf("failed to create rollback deployment: %w", saveErr)
|
||||
}
|
||||
|
||||
_ = deployment.AppendLog(
|
||||
ctx,
|
||||
"Rolling back to previous image: "+app.PreviousImageID.String,
|
||||
)
|
||||
_ = deployment.AppendLog(ctx, "Rolling back to previous image: "+app.PreviousImageID.String)
|
||||
|
||||
return deployment, nil
|
||||
}
|
||||
@@ -437,40 +417,28 @@ func (svc *Service) executeRollback(
|
||||
|
||||
svc.removeOldContainer(ctx, app, deployment)
|
||||
|
||||
rollbackOpts, err := svc.buildContainerOptions(
|
||||
ctx,
|
||||
app,
|
||||
docker.ImageID(previousImageID),
|
||||
)
|
||||
rollbackOpts, err := svc.buildContainerOptions(ctx, app, deployment.ID)
|
||||
if err != nil {
|
||||
svc.failDeployment(bgCtx, app, deployment, err)
|
||||
|
||||
return fmt.Errorf("failed to build container options: %w", err)
|
||||
}
|
||||
|
||||
rollbackOpts.Image = previousImageID
|
||||
|
||||
containerID, err := svc.docker.CreateContainer(ctx, rollbackOpts)
|
||||
if err != nil {
|
||||
svc.failDeployment(
|
||||
bgCtx,
|
||||
app,
|
||||
deployment,
|
||||
fmt.Errorf("failed to create rollback container: %w", err),
|
||||
)
|
||||
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to create rollback container: %w", err))
|
||||
|
||||
return fmt.Errorf("failed to create rollback container: %w", err)
|
||||
}
|
||||
|
||||
deployment.ContainerID = sql.NullString{String: containerID.String(), Valid: true}
|
||||
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID.String())
|
||||
deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
|
||||
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID)
|
||||
|
||||
startErr := svc.docker.StartContainer(ctx, containerID)
|
||||
if startErr != nil {
|
||||
svc.failDeployment(
|
||||
bgCtx,
|
||||
app,
|
||||
deployment,
|
||||
fmt.Errorf("failed to start rollback container: %w", startErr),
|
||||
)
|
||||
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to start rollback container: %w", startErr))
|
||||
|
||||
return fmt.Errorf("failed to start rollback container: %w", startErr)
|
||||
}
|
||||
@@ -526,7 +494,12 @@ func (svc *Service) runBuildAndDeploy(
|
||||
return err
|
||||
}
|
||||
|
||||
err = svc.recordDeployedImage(bgCtx, app, deployment, imageID)
|
||||
// Save current image as previous before updating to new one
|
||||
if app.ImageID.Valid && app.ImageID.String != "" {
|
||||
app.PreviousImageID = app.ImageID
|
||||
}
|
||||
|
||||
err = svc.updateAppRunning(bgCtx, app, imageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -543,7 +516,7 @@ func (svc *Service) buildImageWithTimeout(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
) (docker.ImageID, error) {
|
||||
) (string, error) {
|
||||
buildCtx, cancel := context.WithTimeout(ctx, buildTimeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -568,7 +541,7 @@ func (svc *Service) deployContainerWithTimeout(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
imageID docker.ImageID,
|
||||
imageID string,
|
||||
) error {
|
||||
deployCtx, cancel := context.WithTimeout(ctx, deployTimeout)
|
||||
defer cancel()
|
||||
@@ -696,7 +669,7 @@ func (svc *Service) checkCancelled(
|
||||
bgCtx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
imageID docker.ImageID,
|
||||
imageID string,
|
||||
) error {
|
||||
if !errors.Is(deployCtx.Err(), context.Canceled) {
|
||||
return nil
|
||||
@@ -711,74 +684,12 @@ func (svc *Service) checkCancelled(
|
||||
return ErrDeployCancelled
|
||||
}
|
||||
|
||||
// recordDeployedImage runs once the new container has started: it makes
|
||||
// imageID the app's current image, keeps the replaced one as the previous
|
||||
// image for Rollback, and then removes the app's other images.
|
||||
func (svc *Service) recordDeployedImage(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
imageID docker.ImageID,
|
||||
) error {
|
||||
// Save current image as previous before updating to new one
|
||||
if app.ImageID.Valid && app.ImageID.String != "" {
|
||||
app.PreviousImageID = app.ImageID
|
||||
}
|
||||
|
||||
err := svc.updateAppRunning(ctx, app, imageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc.removeUnusedImages(ctx, app, deployment)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeUnusedImages removes the app's tags (upaas-<app>:<deployment>, set by
|
||||
// buildImage) except those of the image the running container uses and the
|
||||
// one Rollback would start. Docker deletes an image only once no other tag,
|
||||
// such as another app's, and no container still uses it.
|
||||
func (svc *Service) removeUnusedImages(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
) {
|
||||
tags, err := svc.docker.ListImageTags(ctx, "upaas-"+app.Name)
|
||||
if err != nil {
|
||||
svc.log.Error("failed to list app images", "error", err, "app", app.Name)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, tag := range slices.Sorted(maps.Keys(tags)) {
|
||||
imageID := tags[tag].String()
|
||||
if imageID == app.ImageID.String || imageID == app.PreviousImageID.String {
|
||||
continue
|
||||
}
|
||||
|
||||
removeErr := svc.docker.RemoveImageTag(ctx, tag)
|
||||
if removeErr != nil {
|
||||
svc.log.Error("failed to remove old image",
|
||||
"error", removeErr, "app", app.Name, "tag", tag)
|
||||
_ = deployment.AppendLog(
|
||||
ctx,
|
||||
"WARNING: failed to remove old image "+tag+": "+removeErr.Error(),
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
_ = deployment.AppendLog(ctx, "Removed old image: "+tag)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupCancelledDeploy removes orphan resources left by a cancelled deployment.
|
||||
func (svc *Service) cleanupCancelledDeploy(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
imageID docker.ImageID,
|
||||
imageID string,
|
||||
) {
|
||||
// Clean up the intermediate Docker image if one was built
|
||||
if imageID != "" {
|
||||
@@ -786,15 +697,11 @@ func (svc *Service) cleanupCancelledDeploy(
|
||||
if removeErr != nil {
|
||||
svc.log.Error("failed to remove image from cancelled deploy",
|
||||
"error", removeErr, "app", app.Name, "image", imageID)
|
||||
_ = deployment.AppendLog(
|
||||
ctx,
|
||||
"WARNING: failed to clean up image "+
|
||||
imageID.String()+": "+removeErr.Error(),
|
||||
)
|
||||
_ = deployment.AppendLog(ctx, "WARNING: failed to clean up image "+imageID+": "+removeErr.Error())
|
||||
} else {
|
||||
svc.log.Info("cleaned up image from cancelled deploy",
|
||||
"app", app.Name, "image", imageID)
|
||||
_ = deployment.AppendLog(ctx, "Cleaned up intermediate image: "+imageID.String())
|
||||
_ = deployment.AppendLog(ctx, "Cleaned up intermediate image: "+imageID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -819,7 +726,6 @@ func (svc *Service) cleanupCancelledDeploy(
|
||||
} else {
|
||||
svc.log.Info("cleaned up build dir from cancelled deploy",
|
||||
"app", app.Name, "path", dirPath)
|
||||
|
||||
_ = deployment.AppendLog(ctx, "Cleaned up build directory")
|
||||
}
|
||||
}
|
||||
@@ -911,7 +817,7 @@ func (svc *Service) buildImage(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
) (docker.ImageID, error) {
|
||||
) (string, error) {
|
||||
workDir, cleanup, err := svc.cloneRepository(ctx, app, deployment)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -945,8 +851,8 @@ func (svc *Service) buildImage(
|
||||
return "", fmt.Errorf("failed to build image: %w", err)
|
||||
}
|
||||
|
||||
deployment.ImageID = sql.NullString{String: imageID.String(), Valid: true}
|
||||
_ = deployment.AppendLog(ctx, "Image built: "+imageID.String())
|
||||
deployment.ImageID = sql.NullString{String: imageID, Valid: true}
|
||||
_ = deployment.AppendLog(ctx, "Image built: "+imageID)
|
||||
|
||||
return imageID, nil
|
||||
}
|
||||
@@ -965,24 +871,14 @@ func (svc *Service) cloneRepository(
|
||||
|
||||
err := os.MkdirAll(appBuildsDir, buildsDirPermissions)
|
||||
if err != nil {
|
||||
svc.failDeployment(
|
||||
ctx,
|
||||
app,
|
||||
deployment,
|
||||
fmt.Errorf("failed to create builds dir: %w", err),
|
||||
)
|
||||
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create builds dir: %w", err))
|
||||
|
||||
return "", nil, fmt.Errorf("failed to create builds dir: %w", err)
|
||||
}
|
||||
|
||||
buildDir, err := os.MkdirTemp(appBuildsDir, fmt.Sprintf("%d-*", deployment.ID))
|
||||
if err != nil {
|
||||
svc.failDeployment(
|
||||
ctx,
|
||||
app,
|
||||
deployment,
|
||||
fmt.Errorf("failed to create temp dir: %w", err),
|
||||
)
|
||||
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create temp dir: %w", err))
|
||||
|
||||
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
@@ -1013,12 +909,7 @@ func (svc *Service) cloneRepository(
|
||||
)
|
||||
if cloneErr != nil {
|
||||
cleanup()
|
||||
svc.failDeployment(
|
||||
ctx,
|
||||
app,
|
||||
deployment,
|
||||
fmt.Errorf("failed to clone repo: %w", cloneErr),
|
||||
)
|
||||
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to clone repo: %w", cloneErr))
|
||||
|
||||
return "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr)
|
||||
}
|
||||
@@ -1119,16 +1010,16 @@ func (svc *Service) removeOldContainer(
|
||||
svc.log.Warn("failed to remove old container", "error", removeErr)
|
||||
}
|
||||
|
||||
_ = deployment.AppendLog(ctx, "Old container removed: "+string(containerInfo.ID[:12]))
|
||||
_ = deployment.AppendLog(ctx, "Old container removed: "+containerInfo.ID[:12])
|
||||
}
|
||||
|
||||
func (svc *Service) createAndStartContainer(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
imageID docker.ImageID,
|
||||
) (docker.ContainerID, error) {
|
||||
containerOpts, err := svc.buildContainerOptions(ctx, app, imageID)
|
||||
_ string,
|
||||
) (string, error) {
|
||||
containerOpts, err := svc.buildContainerOptions(ctx, app, deployment.ID)
|
||||
if err != nil {
|
||||
svc.failDeployment(ctx, app, deployment, err)
|
||||
|
||||
@@ -1148,8 +1039,8 @@ func (svc *Service) createAndStartContainer(
|
||||
return "", fmt.Errorf("failed to create container: %w", err)
|
||||
}
|
||||
|
||||
deployment.ContainerID = sql.NullString{String: containerID.String(), Valid: true}
|
||||
_ = deployment.AppendLog(ctx, "Container created: "+containerID.String())
|
||||
deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
|
||||
_ = deployment.AppendLog(ctx, "Container created: "+containerID)
|
||||
|
||||
startErr := svc.docker.StartContainer(ctx, containerID)
|
||||
if startErr != nil {
|
||||
@@ -1172,7 +1063,7 @@ func (svc *Service) createAndStartContainer(
|
||||
func (svc *Service) buildContainerOptions(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
imageID docker.ImageID,
|
||||
deploymentID int64,
|
||||
) (docker.CreateContainerOptions, error) {
|
||||
envVars, err := app.GetEnvVars(ctx)
|
||||
if err != nil {
|
||||
@@ -1204,28 +1095,14 @@ func (svc *Service) buildContainerOptions(
|
||||
network = app.DockerNetwork.String
|
||||
}
|
||||
|
||||
var cpuLimit float64
|
||||
|
||||
if app.CPULimit.Valid {
|
||||
cpuLimit = app.CPULimit.Float64
|
||||
}
|
||||
|
||||
var memoryLimit int64
|
||||
|
||||
if app.MemoryLimit.Valid {
|
||||
memoryLimit = app.MemoryLimit.Int64
|
||||
}
|
||||
|
||||
return docker.CreateContainerOptions{
|
||||
Name: "upaas-" + app.Name,
|
||||
Image: imageID.String(),
|
||||
Env: envMap,
|
||||
Labels: buildLabelMap(app, labels),
|
||||
Volumes: buildVolumeMounts(volumes),
|
||||
Ports: buildPortMappings(ports),
|
||||
Network: network,
|
||||
CPULimit: cpuLimit,
|
||||
MemoryLimit: memoryLimit,
|
||||
Name: "upaas-" + app.Name,
|
||||
Image: fmt.Sprintf("upaas-%s:%d", app.Name, deploymentID),
|
||||
Env: envMap,
|
||||
Labels: buildLabelMap(app, labels),
|
||||
Volumes: buildVolumeMounts(volumes),
|
||||
Ports: buildPortMappings(ports),
|
||||
Network: network,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1270,9 +1147,9 @@ func buildPortMappings(ports []*models.Port) []docker.PortMapping {
|
||||
func (svc *Service) updateAppRunning(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
imageID docker.ImageID,
|
||||
imageID string,
|
||||
) error {
|
||||
app.ImageID = sql.NullString{String: imageID.String(), Valid: true}
|
||||
app.ImageID = sql.NullString{String: imageID, Valid: true}
|
||||
app.Status = models.AppStatusRunning
|
||||
|
||||
saveErr := app.Save(ctx)
|
||||
@@ -1352,7 +1229,7 @@ func (svc *Service) failDeployment(
|
||||
}
|
||||
|
||||
// writeLogsToFile writes the deployment logs to a file on disk.
|
||||
// Structure: DataDir/logs/<appname>/<appname>_<sha>_<timestamp>.log.txt
|
||||
// Structure: DataDir/logs/<hostname>/<appname>/<appname>_<sha>_<timestamp>.log.txt
|
||||
func (svc *Service) writeLogsToFile(app *models.App, deployment *models.Deployment) {
|
||||
if !deployment.Logs.Valid || deployment.Logs.String == "" {
|
||||
return
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||
)
|
||||
|
||||
func TestCancelActiveDeploy_NoExisting(t *testing.T) {
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||
)
|
||||
|
||||
func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) {
|
||||
@@ -32,10 +32,7 @@ func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) {
|
||||
require.NoError(t, os.MkdirAll(deployDir, 0o750))
|
||||
|
||||
// Create a file inside to verify full removal
|
||||
require.NoError(
|
||||
t,
|
||||
os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o600),
|
||||
)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o640))
|
||||
|
||||
// Also create a dir for a different deployment (should NOT be removed)
|
||||
otherDir := filepath.Join(buildDir, "99-xyz789")
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
package deploy_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"sneak.berlin/go/upaas/internal/docker"
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
||||
)
|
||||
|
||||
func TestBuildContainerOptionsUsesImageID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDatabase(t)
|
||||
|
||||
app := models.NewApp(db)
|
||||
app.Name = "myapp"
|
||||
|
||||
err := app.Save(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to save app: %v", err)
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
svc := deploy.NewTestService(log)
|
||||
|
||||
const expectedImageID = docker.ImageID("sha256:abc123def456")
|
||||
|
||||
opts, err := svc.BuildContainerOptionsExported(
|
||||
context.Background(), app, expectedImageID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("buildContainerOptions returned error: %v", err)
|
||||
}
|
||||
|
||||
if opts.Image != expectedImageID.String() {
|
||||
t.Errorf("expected Image=%q, got %q", expectedImageID, opts.Image)
|
||||
}
|
||||
|
||||
if opts.Name != "upaas-myapp" {
|
||||
t.Errorf("expected Name=%q, got %q", "upaas-myapp", opts.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContainerOptionsNoResourceLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDatabase(t)
|
||||
|
||||
app := models.NewApp(db)
|
||||
app.Name = "nolimits"
|
||||
|
||||
err := app.Save(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to save app: %v", err)
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
svc := deploy.NewTestService(log)
|
||||
|
||||
opts, err := svc.BuildContainerOptionsExported(
|
||||
context.Background(), app, docker.ImageID("test:latest"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("buildContainerOptions returned error: %v", err)
|
||||
}
|
||||
|
||||
if opts.CPULimit != 0 {
|
||||
t.Errorf("expected CPULimit=0, got %v", opts.CPULimit)
|
||||
}
|
||||
|
||||
if opts.MemoryLimit != 0 {
|
||||
t.Errorf("expected MemoryLimit=0, got %v", opts.MemoryLimit)
|
||||
}
|
||||
}
|
||||
|
||||
// buildOptsForApp saves an app configured by setup and returns the container
|
||||
// options built for it.
|
||||
func buildOptsForApp(
|
||||
t *testing.T,
|
||||
name string,
|
||||
setup func(app *models.App),
|
||||
) docker.CreateContainerOptions {
|
||||
t.Helper()
|
||||
|
||||
db := database.NewTestDatabase(t)
|
||||
|
||||
app := models.NewApp(db)
|
||||
app.Name = name
|
||||
setup(app)
|
||||
|
||||
err := app.Save(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to save app: %v", err)
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
svc := deploy.NewTestService(log)
|
||||
|
||||
opts, err := svc.BuildContainerOptionsExported(
|
||||
context.Background(), app, docker.ImageID("test:latest"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("buildContainerOptions returned error: %v", err)
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func TestBuildContainerOptionsCPULimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := buildOptsForApp(t, "cpulimit", func(app *models.App) {
|
||||
app.CPULimit = sql.NullFloat64{Float64: 0.5, Valid: true}
|
||||
})
|
||||
|
||||
if opts.CPULimit != 0.5 {
|
||||
t.Errorf("expected CPULimit=0.5, got %v", opts.CPULimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContainerOptionsMemoryLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := buildOptsForApp(t, "memlimit", func(app *models.App) {
|
||||
app.MemoryLimit = sql.NullInt64{Int64: 536870912, Valid: true} // 512m
|
||||
})
|
||||
|
||||
if opts.MemoryLimit != 536870912 {
|
||||
t.Errorf("expected MemoryLimit=536870912, got %v", opts.MemoryLimit)
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package deploy_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx/fxtest"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"sneak.berlin/go/upaas/internal/docker"
|
||||
"sneak.berlin/go/upaas/internal/logger"
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
||||
)
|
||||
|
||||
// TestRecordDeployedImageRemovesOldImages runs the step after a deploy
|
||||
// against a fake Docker API. Image one is also tagged for another app,
|
||||
// image two was the previous image, three the current one, four is new.
|
||||
func TestRecordDeployedImageRemovesOldImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
removed []string
|
||||
forced bool
|
||||
)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch {
|
||||
case r.Method == http.MethodDelete:
|
||||
_, name, _ := strings.Cut(r.URL.Path, "/images/")
|
||||
|
||||
mu.Lock()
|
||||
|
||||
removed = append(removed, name)
|
||||
forced = forced || r.URL.Query().Get("force") != ""
|
||||
mu.Unlock()
|
||||
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
case strings.HasSuffix(r.URL.Path, "/images/json"):
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"Id":"sha256:one","RepoTags":["upaas-myapp:1","upaas-otherapp:7"]},
|
||||
{"Id":"sha256:two","RepoTags":["upaas-myapp:2"]},
|
||||
{"Id":"sha256:three","RepoTags":["upaas-myapp:3"]},
|
||||
{"Id":"sha256:four","RepoTags":["upaas-myapp:4"]}
|
||||
]`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}
|
||||
},
|
||||
))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
lifecycle := fxtest.NewLifecycle(t)
|
||||
|
||||
dockerClient, err := docker.New(lifecycle, docker.Params{
|
||||
Logger: logger.NewForTest(log),
|
||||
Config: &config.Config{DockerHost: "tcp://" + srv.Listener.Addr().String()},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
lifecycle.RequireStart()
|
||||
t.Cleanup(lifecycle.RequireStop)
|
||||
|
||||
db := database.NewTestDatabase(t)
|
||||
ctx := context.Background()
|
||||
|
||||
app := models.NewApp(db)
|
||||
app.ID = "myapp-id"
|
||||
app.Name = "myapp"
|
||||
app.ImageID = sql.NullString{String: "sha256:three", Valid: true}
|
||||
app.PreviousImageID = sql.NullString{String: "sha256:two", Valid: true}
|
||||
require.NoError(t, app.Save(ctx))
|
||||
|
||||
deployment := models.NewDeployment(db)
|
||||
deployment.AppID = app.ID
|
||||
require.NoError(t, deployment.Save(ctx))
|
||||
|
||||
svc := deploy.NewTestServiceWithConfig(log, &config.Config{}, dockerClient)
|
||||
|
||||
err = svc.RecordDeployedImage(ctx, app, deployment, "sha256:four")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "sha256:four", app.ImageID.String)
|
||||
assert.Equal(t, "sha256:three", app.PreviousImageID.String)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
assert.Equal(t, []string{"upaas-myapp:1", "upaas-myapp:2"}, removed)
|
||||
assert.False(t, forced, "old image tags must be removed without force")
|
||||
}
|
||||
@@ -8,9 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/config"
|
||||
"sneak.berlin/go/upaas/internal/docker"
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/docker"
|
||||
)
|
||||
|
||||
// NewTestService creates a Service with minimal dependencies for testing.
|
||||
@@ -26,11 +25,7 @@ func (svc *Service) CancelActiveDeploy(appID string) {
|
||||
}
|
||||
|
||||
// RegisterActiveDeploy registers an active deploy for testing.
|
||||
func (svc *Service) RegisterActiveDeploy(
|
||||
appID string,
|
||||
cancel context.CancelFunc,
|
||||
done chan struct{},
|
||||
) {
|
||||
func (svc *Service) RegisterActiveDeploy(appID string, cancel context.CancelFunc, done chan struct{}) {
|
||||
svc.activeDeploys.Store(appID, &activeDeploy{cancel: cancel, done: done})
|
||||
}
|
||||
|
||||
@@ -45,11 +40,7 @@ func (svc *Service) UnlockApp(appID string) {
|
||||
}
|
||||
|
||||
// NewTestServiceWithConfig creates a Service with config and docker client for testing.
|
||||
func NewTestServiceWithConfig(
|
||||
log *slog.Logger,
|
||||
cfg *config.Config,
|
||||
dockerClient *docker.Client,
|
||||
) *Service {
|
||||
func NewTestServiceWithConfig(log *slog.Logger, cfg *config.Config, dockerClient *docker.Client) *Service {
|
||||
return &Service{
|
||||
log: log,
|
||||
config: cfg,
|
||||
@@ -61,10 +52,10 @@ func NewTestServiceWithConfig(
|
||||
// cleanupCancelledDeploy for testing. It removes build directories matching
|
||||
// the deployment ID prefix.
|
||||
func (svc *Service) CleanupCancelledDeploy(
|
||||
_ context.Context,
|
||||
ctx context.Context,
|
||||
appName string,
|
||||
deploymentID int64,
|
||||
_ string,
|
||||
imageID string,
|
||||
) {
|
||||
// We can't create real models.App/Deployment in tests easily,
|
||||
// so we test the build dir cleanup portion directly.
|
||||
@@ -89,22 +80,3 @@ func (svc *Service) CleanupCancelledDeploy(
|
||||
func (svc *Service) GetBuildDirExported(appName string) string {
|
||||
return svc.GetBuildDir(appName)
|
||||
}
|
||||
|
||||
// RecordDeployedImage exposes recordDeployedImage for testing.
|
||||
func (svc *Service) RecordDeployedImage(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
imageID docker.ImageID,
|
||||
) error {
|
||||
return svc.recordDeployedImage(ctx, app, deployment, imageID)
|
||||
}
|
||||
|
||||
// BuildContainerOptionsExported exposes buildContainerOptions for testing.
|
||||
func (svc *Service) BuildContainerOptionsExported(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
imageID docker.ImageID,
|
||||
) (docker.CreateContainerOptions, error) {
|
||||
return svc.buildContainerOptions(ctx, app, imageID)
|
||||
}
|
||||
|
||||
@@ -10,13 +10,12 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/logger"
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
)
|
||||
|
||||
// HTTP client timeout.
|
||||
@@ -159,8 +158,7 @@ func (svc *Service) NotifyDeployFailed(
|
||||
) {
|
||||
duration := time.Since(deployment.StartedAt)
|
||||
title := "Deploy failed: " + app.Name
|
||||
message := "Deployment failed after " + formatDuration(duration) +
|
||||
": " + deployErr.Error()
|
||||
message := "Deployment failed after " + formatDuration(duration) + ": " + deployErr.Error()
|
||||
|
||||
svc.sendNotifications(ctx, app, title, message, message, "error")
|
||||
}
|
||||
@@ -249,15 +247,10 @@ func (svc *Service) sendNtfy(
|
||||
) error {
|
||||
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
|
||||
|
||||
parsedURL, err := url.ParseRequestURI(topic)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid ntfy topic URL: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
parsedURL.String(),
|
||||
topic,
|
||||
bytes.NewBufferString(message),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -267,7 +260,6 @@ func (svc *Service) sendNtfy(
|
||||
request.Header.Set("Title", title)
|
||||
request.Header.Set("Priority", svc.ntfyPriority(priority))
|
||||
|
||||
// #nosec G704 -- URL from validated config, not user input
|
||||
resp, err := svc.client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send ntfy request: %w", err)
|
||||
@@ -348,15 +340,10 @@ func (svc *Service) sendSlack(
|
||||
return fmt.Errorf("failed to marshal slack payload: %w", err)
|
||||
}
|
||||
|
||||
parsedWebhookURL, err := url.ParseRequestURI(webhookURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid slack webhook URL: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
parsedWebhookURL.String(),
|
||||
webhookURL,
|
||||
bytes.NewBuffer(body),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -365,7 +352,6 @@ func (svc *Service) sendSlack(
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// #nosec G704 -- URL from validated config, not user input
|
||||
resp, err := svc.client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send slack request: %w", err)
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
package webhook
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// GiteaPushPayload represents a Gitea push webhook payload.
|
||||
//
|
||||
//nolint:tagliatelle // Field names match Gitea API (snake_case)
|
||||
type GiteaPushPayload struct {
|
||||
Ref string `json:"ref"`
|
||||
Before string `json:"before"`
|
||||
After string `json:"after"`
|
||||
CompareURL UnparsedURL `json:"compare_url"`
|
||||
Repository struct {
|
||||
FullName string `json:"full_name"`
|
||||
CloneURL UnparsedURL `json:"clone_url"`
|
||||
SSHURL string `json:"ssh_url"`
|
||||
HTMLURL UnparsedURL `json:"html_url"`
|
||||
} `json:"repository"`
|
||||
Pusher struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
} `json:"pusher"`
|
||||
Commits []struct {
|
||||
ID string `json:"id"`
|
||||
URL UnparsedURL `json:"url"`
|
||||
Message string `json:"message"`
|
||||
Author struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
} `json:"author"`
|
||||
} `json:"commits"`
|
||||
}
|
||||
|
||||
// GitHubPushPayload represents a GitHub push webhook payload.
|
||||
//
|
||||
//nolint:tagliatelle // Field names match GitHub API (snake_case)
|
||||
type GitHubPushPayload struct {
|
||||
Ref string `json:"ref"`
|
||||
Before string `json:"before"`
|
||||
After string `json:"after"`
|
||||
CompareURL string `json:"compare"`
|
||||
Repository struct {
|
||||
FullName string `json:"full_name"`
|
||||
CloneURL UnparsedURL `json:"clone_url"`
|
||||
SSHURL string `json:"ssh_url"`
|
||||
HTMLURL UnparsedURL `json:"html_url"`
|
||||
} `json:"repository"`
|
||||
Pusher struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
} `json:"pusher"`
|
||||
HeadCommit *struct {
|
||||
ID string `json:"id"`
|
||||
URL UnparsedURL `json:"url"`
|
||||
Message string `json:"message"`
|
||||
} `json:"head_commit"`
|
||||
Commits []struct {
|
||||
ID string `json:"id"`
|
||||
URL UnparsedURL `json:"url"`
|
||||
Message string `json:"message"`
|
||||
Author struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
} `json:"author"`
|
||||
} `json:"commits"`
|
||||
}
|
||||
|
||||
// GitLabPushPayload represents a GitLab push webhook payload.
|
||||
//
|
||||
//nolint:tagliatelle // Field names match GitLab API (snake_case)
|
||||
type GitLabPushPayload struct {
|
||||
Ref string `json:"ref"`
|
||||
Before string `json:"before"`
|
||||
After string `json:"after"`
|
||||
UserName string `json:"user_name"`
|
||||
UserEmail string `json:"user_email"`
|
||||
Project struct {
|
||||
PathWithNamespace string `json:"path_with_namespace"`
|
||||
GitHTTPURL UnparsedURL `json:"git_http_url"`
|
||||
GitSSHURL string `json:"git_ssh_url"`
|
||||
WebURL UnparsedURL `json:"web_url"`
|
||||
} `json:"project"`
|
||||
Commits []struct {
|
||||
ID string `json:"id"`
|
||||
URL UnparsedURL `json:"url"`
|
||||
Message string `json:"message"`
|
||||
Author struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
} `json:"author"`
|
||||
} `json:"commits"`
|
||||
}
|
||||
|
||||
// ParsePushPayload parses a raw webhook payload into a normalized PushEvent
|
||||
// based on the detected webhook source. Returns an error if JSON unmarshaling
|
||||
// fails. For SourceUnknown, falls back to Gitea format for backward
|
||||
// compatibility.
|
||||
func ParsePushPayload(source Source, payload []byte) (*PushEvent, error) {
|
||||
switch source {
|
||||
case SourceGitHub:
|
||||
return parsePush(payload, githubPushEvent)
|
||||
case SourceGitLab:
|
||||
return parsePush(payload, gitlabPushEvent)
|
||||
case SourceGitea, SourceUnknown:
|
||||
// Gitea and unknown both use Gitea format for backward compatibility.
|
||||
return parsePush(payload, giteaPushEvent)
|
||||
}
|
||||
|
||||
// Unreachable for known source values, but satisfies exhaustive checker.
|
||||
return parsePush(payload, giteaPushEvent)
|
||||
}
|
||||
|
||||
// parsePush unmarshals payload into P and converts it into a normalized
|
||||
// PushEvent via build.
|
||||
func parsePush[P any](payload []byte, build func(P) *PushEvent) (*PushEvent, error) {
|
||||
var p P
|
||||
|
||||
unmarshalErr := json.Unmarshal(payload, &p)
|
||||
if unmarshalErr != nil {
|
||||
return nil, unmarshalErr
|
||||
}
|
||||
|
||||
return build(p), nil
|
||||
}
|
||||
|
||||
// basePushEvent builds a PushEvent populated with the fields shared by all
|
||||
// webhook sources.
|
||||
func basePushEvent(source Source, ref, before, after string) *PushEvent {
|
||||
return &PushEvent{
|
||||
Source: source,
|
||||
Ref: ref,
|
||||
Before: before,
|
||||
After: after,
|
||||
Branch: extractBranch(ref),
|
||||
}
|
||||
}
|
||||
|
||||
// giteaPushEvent converts a Gitea push payload to a normalized PushEvent.
|
||||
func giteaPushEvent(p GiteaPushPayload) *PushEvent {
|
||||
event := basePushEvent(SourceGitea, p.Ref, p.Before, p.After)
|
||||
event.RepoName = p.Repository.FullName
|
||||
event.CloneURL = p.Repository.CloneURL
|
||||
event.HTMLURL = p.Repository.HTMLURL
|
||||
event.CommitURL = extractGiteaCommitURL(p)
|
||||
event.Pusher = p.Pusher.Username
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// gitlabPushEvent converts a GitLab push payload to a normalized PushEvent.
|
||||
func gitlabPushEvent(p GitLabPushPayload) *PushEvent {
|
||||
event := basePushEvent(SourceGitLab, p.Ref, p.Before, p.After)
|
||||
event.RepoName = p.Project.PathWithNamespace
|
||||
event.CloneURL = p.Project.GitHTTPURL
|
||||
event.HTMLURL = p.Project.WebURL
|
||||
event.CommitURL = extractGitLabCommitURL(p)
|
||||
event.Pusher = p.UserName
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// githubPushEvent converts a GitHub push payload to a normalized PushEvent.
|
||||
func githubPushEvent(p GitHubPushPayload) *PushEvent {
|
||||
event := basePushEvent(SourceGitHub, p.Ref, p.Before, p.After)
|
||||
event.RepoName = p.Repository.FullName
|
||||
event.CloneURL = p.Repository.CloneURL
|
||||
event.HTMLURL = p.Repository.HTMLURL
|
||||
event.CommitURL = extractGitHubCommitURL(p)
|
||||
event.Pusher = p.Pusher.Name
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// extractBranch extracts the branch name from a git ref.
|
||||
func extractBranch(ref string) string {
|
||||
// refs/heads/main -> main
|
||||
const prefix = "refs/heads/"
|
||||
|
||||
if len(ref) >= len(prefix) && ref[:len(prefix)] == prefix {
|
||||
return ref[len(prefix):]
|
||||
}
|
||||
|
||||
return ref
|
||||
}
|
||||
|
||||
// extractGiteaCommitURL extracts the commit URL from a Gitea push payload.
|
||||
// Prefers the URL from the head commit, falls back to constructing from repo URL.
|
||||
func extractGiteaCommitURL(payload GiteaPushPayload) UnparsedURL {
|
||||
for _, commit := range payload.Commits {
|
||||
if commit.ID == payload.After && commit.URL != "" {
|
||||
return commit.URL
|
||||
}
|
||||
}
|
||||
|
||||
if payload.Repository.HTMLURL != "" && payload.After != "" {
|
||||
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractGitHubCommitURL extracts the commit URL from a GitHub push payload.
|
||||
// Prefers head_commit.url, then searches commits, then constructs from repo URL.
|
||||
func extractGitHubCommitURL(payload GitHubPushPayload) UnparsedURL {
|
||||
if payload.HeadCommit != nil && payload.HeadCommit.URL != "" {
|
||||
return payload.HeadCommit.URL
|
||||
}
|
||||
|
||||
for _, commit := range payload.Commits {
|
||||
if commit.ID == payload.After && commit.URL != "" {
|
||||
return commit.URL
|
||||
}
|
||||
}
|
||||
|
||||
if payload.Repository.HTMLURL != "" && payload.After != "" {
|
||||
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractGitLabCommitURL extracts the commit URL from a GitLab push payload.
|
||||
// Prefers commit URL from the commits list, falls back to constructing from
|
||||
// project web URL.
|
||||
func extractGitLabCommitURL(payload GitLabPushPayload) UnparsedURL {
|
||||
for _, commit := range payload.Commits {
|
||||
if commit.ID == payload.After && commit.URL != "" {
|
||||
return commit.URL
|
||||
}
|
||||
}
|
||||
|
||||
if payload.Project.WebURL != "" && payload.After != "" {
|
||||
return UnparsedURL(payload.Project.WebURL.String() + "/-/commit/" + payload.After)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package webhook
|
||||
|
||||
import "net/http"
|
||||
|
||||
// UnparsedURL is a URL stored as a plain string without parsing.
|
||||
// Use this instead of string when the value is known to be a URL
|
||||
// but should not be parsed into a net/url.URL (e.g. webhook URLs,
|
||||
// compare URLs from external payloads).
|
||||
type UnparsedURL string
|
||||
|
||||
// String implements the fmt.Stringer interface.
|
||||
func (u UnparsedURL) String() string { return string(u) }
|
||||
|
||||
// Source identifies which git hosting platform sent the webhook.
|
||||
type Source string
|
||||
|
||||
const (
|
||||
// SourceGitea indicates the webhook was sent by a Gitea instance.
|
||||
SourceGitea Source = "gitea"
|
||||
|
||||
// SourceGitHub indicates the webhook was sent by GitHub.
|
||||
SourceGitHub Source = "github"
|
||||
|
||||
// SourceGitLab indicates the webhook was sent by a GitLab instance.
|
||||
SourceGitLab Source = "gitlab"
|
||||
|
||||
// SourceUnknown indicates the webhook source could not be determined.
|
||||
SourceUnknown Source = "unknown"
|
||||
)
|
||||
|
||||
// String implements the fmt.Stringer interface.
|
||||
func (s Source) String() string { return string(s) }
|
||||
|
||||
// DetectWebhookSource determines the webhook source from HTTP headers.
|
||||
// It checks for platform-specific event headers in this order:
|
||||
// Gitea (X-Gitea-Event), GitHub (X-GitHub-Event), GitLab (X-Gitlab-Event).
|
||||
// Returns SourceUnknown if no recognized header is found.
|
||||
func DetectWebhookSource(headers http.Header) Source {
|
||||
if headers.Get("X-Gitea-Event") != "" {
|
||||
return SourceGitea
|
||||
}
|
||||
|
||||
if headers.Get("X-Github-Event") != "" {
|
||||
return SourceGitHub
|
||||
}
|
||||
|
||||
if headers.Get("X-Gitlab-Event") != "" {
|
||||
return SourceGitLab
|
||||
}
|
||||
|
||||
return SourceUnknown
|
||||
}
|
||||
|
||||
// DetectEventType extracts the event type string from HTTP headers
|
||||
// based on the detected webhook source. Returns "push" as a fallback
|
||||
// when no event header is found.
|
||||
func DetectEventType(headers http.Header, source Source) string {
|
||||
switch source {
|
||||
case SourceGitea:
|
||||
if v := headers.Get("X-Gitea-Event"); v != "" {
|
||||
return v
|
||||
}
|
||||
case SourceGitHub:
|
||||
if v := headers.Get("X-Github-Event"); v != "" {
|
||||
return v
|
||||
}
|
||||
case SourceGitLab:
|
||||
if v := headers.Get("X-Gitlab-Event"); v != "" {
|
||||
return v
|
||||
}
|
||||
case SourceUnknown:
|
||||
// Fall through to default
|
||||
}
|
||||
|
||||
return "push"
|
||||
}
|
||||
|
||||
// PushEvent is a normalized representation of a push webhook payload
|
||||
// from any supported source (Gitea, GitHub, GitLab). The webhook
|
||||
// service converts source-specific payloads into this format before
|
||||
// processing.
|
||||
type PushEvent struct {
|
||||
Source Source
|
||||
Ref string
|
||||
Before string
|
||||
After string
|
||||
Branch string
|
||||
RepoName string
|
||||
CloneURL UnparsedURL
|
||||
HTMLURL UnparsedURL
|
||||
CommitURL UnparsedURL
|
||||
Pusher string
|
||||
}
|
||||
@@ -4,17 +4,16 @@ package webhook
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/logger"
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||
)
|
||||
|
||||
// ServiceParams contains dependencies for Service.
|
||||
@@ -32,10 +31,6 @@ type Service struct {
|
||||
db *database.Database
|
||||
deploy *deploy.Service
|
||||
params *ServiceParams
|
||||
|
||||
// deployments tracks the deployment goroutines started by
|
||||
// triggerDeployment so callers can wait for them to finish.
|
||||
deployments sync.WaitGroup
|
||||
}
|
||||
|
||||
// New creates a new webhook Service.
|
||||
@@ -48,46 +43,68 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HandleWebhook processes a webhook request from any supported source
|
||||
// (Gitea, GitHub, or GitLab). The source parameter determines which
|
||||
// payload format to use for parsing.
|
||||
// GiteaPushPayload represents a Gitea push webhook payload.
|
||||
//
|
||||
//nolint:tagliatelle // Field names match Gitea API (snake_case)
|
||||
type GiteaPushPayload struct {
|
||||
Ref string `json:"ref"`
|
||||
Before string `json:"before"`
|
||||
After string `json:"after"`
|
||||
CompareURL string `json:"compare_url"`
|
||||
Repository struct {
|
||||
FullName string `json:"full_name"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
SSHURL string `json:"ssh_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
} `json:"repository"`
|
||||
Pusher struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
} `json:"pusher"`
|
||||
Commits []struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Message string `json:"message"`
|
||||
Author struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
} `json:"author"`
|
||||
} `json:"commits"`
|
||||
}
|
||||
|
||||
// HandleWebhook processes a webhook request.
|
||||
func (svc *Service) HandleWebhook(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
source Source,
|
||||
eventType string,
|
||||
payload []byte,
|
||||
) error {
|
||||
svc.log.Info("processing webhook",
|
||||
"app", app.Name,
|
||||
"source", source.String(),
|
||||
"event", eventType,
|
||||
)
|
||||
svc.log.Info("processing webhook", "app", app.Name, "event", eventType)
|
||||
|
||||
// Parse payload into normalized push event
|
||||
pushEvent, parseErr := ParsePushPayload(source, payload)
|
||||
if parseErr != nil {
|
||||
svc.log.Warn("failed to parse webhook payload",
|
||||
"error", parseErr,
|
||||
"source", source.String(),
|
||||
)
|
||||
// Continue with empty push event to still log the webhook
|
||||
pushEvent = &PushEvent{Source: source}
|
||||
// Parse payload
|
||||
var pushPayload GiteaPushPayload
|
||||
|
||||
unmarshalErr := json.Unmarshal(payload, &pushPayload)
|
||||
if unmarshalErr != nil {
|
||||
svc.log.Warn("failed to parse webhook payload", "error", unmarshalErr)
|
||||
// Continue anyway to log the event
|
||||
}
|
||||
|
||||
// Extract branch from ref
|
||||
branch := extractBranch(pushPayload.Ref)
|
||||
commitSHA := pushPayload.After
|
||||
commitURL := extractCommitURL(pushPayload)
|
||||
|
||||
// Check if branch matches
|
||||
matched := pushEvent.Branch == app.Branch
|
||||
matched := branch == app.Branch
|
||||
|
||||
// Create webhook event record
|
||||
event := models.NewWebhookEvent(svc.db)
|
||||
event.AppID = app.ID
|
||||
event.EventType = eventType
|
||||
event.Branch = pushEvent.Branch
|
||||
event.CommitSHA = sql.NullString{String: pushEvent.After, Valid: pushEvent.After != ""}
|
||||
event.CommitURL = sql.NullString{
|
||||
String: pushEvent.CommitURL.String(),
|
||||
Valid: pushEvent.CommitURL != "",
|
||||
}
|
||||
event.Branch = branch
|
||||
event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""}
|
||||
event.CommitURL = sql.NullString{String: commitURL, Valid: commitURL != ""}
|
||||
event.Payload = sql.NullString{String: string(payload), Valid: true}
|
||||
event.Matched = matched
|
||||
event.Processed = false
|
||||
@@ -99,10 +116,9 @@ func (svc *Service) HandleWebhook(
|
||||
|
||||
svc.log.Info("webhook event recorded",
|
||||
"app", app.Name,
|
||||
"source", source.String(),
|
||||
"branch", pushEvent.Branch,
|
||||
"branch", branch,
|
||||
"matched", matched,
|
||||
"commit", pushEvent.After,
|
||||
"commit", commitSHA,
|
||||
)
|
||||
|
||||
// If branch matches, trigger deployment
|
||||
@@ -113,14 +129,6 @@ func (svc *Service) HandleWebhook(
|
||||
return nil
|
||||
}
|
||||
|
||||
// WaitForDeployments blocks until every deployment goroutine started by
|
||||
// HandleWebhook has finished, including all writes under the data
|
||||
// directory. It exists so callers and tests can synchronize on async
|
||||
// deployment completion instead of polling or sleeping.
|
||||
func (svc *Service) WaitForDeployments() {
|
||||
svc.deployments.Wait()
|
||||
}
|
||||
|
||||
func (svc *Service) triggerDeployment(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
@@ -130,7 +138,7 @@ func (svc *Service) triggerDeployment(
|
||||
eventID := event.ID
|
||||
appName := app.Name
|
||||
|
||||
svc.deployments.Go(func() {
|
||||
go func() {
|
||||
// Use context.WithoutCancel to ensure deployment completes
|
||||
// even if the HTTP request context is cancelled.
|
||||
deployCtx := context.WithoutCancel(ctx)
|
||||
@@ -143,5 +151,35 @@ func (svc *Service) triggerDeployment(
|
||||
// Mark event as processed
|
||||
event.Processed = true
|
||||
_ = event.Save(deployCtx)
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// extractBranch extracts the branch name from a git ref.
|
||||
func extractBranch(ref string) string {
|
||||
// refs/heads/main -> main
|
||||
const prefix = "refs/heads/"
|
||||
|
||||
if len(ref) >= len(prefix) && ref[:len(prefix)] == prefix {
|
||||
return ref[len(prefix):]
|
||||
}
|
||||
|
||||
return ref
|
||||
}
|
||||
|
||||
// extractCommitURL extracts the commit URL from the webhook payload.
|
||||
// Prefers the URL from the head commit, falls back to constructing from repo URL.
|
||||
func extractCommitURL(payload GiteaPushPayload) string {
|
||||
// Try to find the URL from the head commit (matching After SHA)
|
||||
for _, commit := range payload.Commits {
|
||||
if commit.ID == payload.After && commit.URL != "" {
|
||||
return commit.URL
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to constructing URL from repo HTML URL
|
||||
if payload.Repository.HTMLURL != "" && payload.After != "" {
|
||||
return payload.Repository.HTMLURL + "/commit/" + payload.After
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
// KeyPair contains an SSH key pair.
|
||||
type KeyPair struct {
|
||||
PrivateKey string `json:"-"`
|
||||
PrivateKey string
|
||||
PublicKey string
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/ssh"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/upaas/internal/ssh"
|
||||
)
|
||||
|
||||
func TestGenerateKeyPair(t *testing.T) {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1"
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/bootstrap: install all dependencies needed to build and develop
|
||||
# this repo. Idempotent: every install is guarded by a check so already
|
||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||
# make, or go). goimports is installed with `go install` at a pinned
|
||||
# version (integrity via the Go module checksum database) into
|
||||
# /usr/local/bin so it is on PATH. Node is used directly if installed;
|
||||
# otherwise it is installed at a pinned version via nvm (installing nvm
|
||||
# itself first, from a hash-verified release archive, never curl | sh),
|
||||
# then the pinned prettier from yarn.lock. The linter is not installed
|
||||
# here: it runs only in Docker via script/lint, so docker is its sole
|
||||
# prerequisite.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Pinned versions. Never "latest"; exact versions only.
|
||||
# golang.org/x/tools goimports, 2026-08-13. v0.49.0 requires Go 1.25 (matches
|
||||
# go.mod); v0.50.0 needs Go 1.26. Integrity via the Go module checksum database.
|
||||
GOIMPORTS_VERSION="v0.49.0"
|
||||
# Node/yarn toolchain, 2026-07-06.
|
||||
NODE_VERSION="22.17.0"
|
||||
NVM_VERSION="0.40.3"
|
||||
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
|
||||
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
|
||||
YARN_VERSION="1.22.22"
|
||||
|
||||
PKGMGR=""
|
||||
SUDO=""
|
||||
|
||||
detect_pkgmgr() {
|
||||
[ -n "$PKGMGR" ] && return 0
|
||||
if command -v nix-env >/dev/null 2>&1; then
|
||||
PKGMGR="nix"
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
PKGMGR="apt"
|
||||
elif command -v brew >/dev/null 2>&1; then
|
||||
PKGMGR="brew"
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
PKGMGR="apk"
|
||||
else
|
||||
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$PKGMGR" = "apt" ]; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
if [ "$(id -u)" != "0" ]; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
|
||||
pkg_install() {
|
||||
detect_pkgmgr
|
||||
case "$PKGMGR" in
|
||||
nix) nix-env -iA "nixpkgs.$1" ;;
|
||||
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
|
||||
brew) brew install "$3" ;;
|
||||
apk) apk add --no-cache "$4" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
missing() {
|
||||
! command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# verify_sha256 <file> <expected-hash>
|
||||
verify_sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
||||
else
|
||||
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
||||
fi
|
||||
if [ "$actual" != "$2" ]; then
|
||||
echo "bootstrap: sha256 mismatch for $1" >&2
|
||||
echo " expected: $2" >&2
|
||||
echo " actual: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# goimports is not packaged uniformly across nix/apt/brew/apk, so install it
|
||||
# with `go install` at a pinned version and place the binary in /usr/local/bin
|
||||
# so it is on PATH regardless of shell config. Requires go, which main
|
||||
# installs first.
|
||||
ensure_goimports() {
|
||||
if ! missing goimports; then return 0; fi
|
||||
detect_pkgmgr
|
||||
tmp="$(mktemp -d)"
|
||||
GOBIN="$tmp" go install "golang.org/x/tools/cmd/goimports@${GOIMPORTS_VERSION}"
|
||||
$SUDO install -m 0755 "$tmp/goimports" /usr/local/bin/goimports
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
# nvm is a bash script; run a command in a bash with nvm loaded
|
||||
nvm_sh() {
|
||||
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
|
||||
}
|
||||
|
||||
ensure_nvm() {
|
||||
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
|
||||
# nvm prerequisites; nvm itself requires bash
|
||||
if missing bash; then pkg_install bash bash bash bash; fi
|
||||
if missing curl; then pkg_install curl curl curl curl; fi
|
||||
if missing git; then pkg_install git git git git; fi
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL -o "$tmp/nvm.tar.gz" \
|
||||
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
|
||||
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
|
||||
mkdir -p "$HOME/.nvm"
|
||||
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
ensure_node() {
|
||||
if ! missing node; then return 0; fi
|
||||
ensure_nvm
|
||||
nvm_sh "nvm install $NODE_VERSION"
|
||||
}
|
||||
|
||||
ensure_yarn() {
|
||||
if ! missing yarn; then return 0; fi
|
||||
if ! missing corepack; then
|
||||
corepack enable
|
||||
corepack prepare "yarn@$YARN_VERSION" --activate
|
||||
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
|
||||
corepack prepare yarn@$YARN_VERSION --activate"
|
||||
else
|
||||
npm install -g "yarn@$YARN_VERSION"
|
||||
fi
|
||||
}
|
||||
|
||||
install_js_deps() {
|
||||
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
|
||||
yarn install --frozen-lockfile"
|
||||
else
|
||||
yarn install --frozen-lockfile
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
# Base tooling
|
||||
if missing git; then pkg_install git git git git; fi
|
||||
if missing make; then pkg_install gnumake make make make; fi
|
||||
|
||||
# Go toolchain
|
||||
if missing go; then pkg_install go golang go go; fi
|
||||
ensure_goimports
|
||||
|
||||
# Node toolchain and pinned prettier
|
||||
ensure_node
|
||||
ensure_yarn
|
||||
install_js_deps
|
||||
|
||||
# The linter runs only in Docker (script/lint). Warn, don't fail: the
|
||||
# rest of the repo works without it.
|
||||
if missing docker; then
|
||||
echo "bootstrap: WARNING: docker not found; make lint and" >&2
|
||||
echo "bootstrap: make check require it. Install docker to run" >&2
|
||||
echo "bootstrap: the linter." >&2
|
||||
fi
|
||||
|
||||
go mod download
|
||||
|
||||
echo "bootstrap complete"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/check: run all checks (test, lint, fmt-check). Our own
|
||||
# extension to scripts-to-rule-them-all. Must not modify any files.
|
||||
# Generic: usually needs no adaptation.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
|
||||
main() {
|
||||
"$SCRIPT_DIR/test"
|
||||
"$SCRIPT_DIR/lint"
|
||||
"$SCRIPT_DIR/fmt-check"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/cibuild: run the CI build. The Dockerfile runs the checks
|
||||
# (make fmt-check, lint, test), so a successful build implies a green
|
||||
# repo. Generic: needs no adaptation. The Gitea workflow runs this on
|
||||
# push.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/docker: build the Docker image tagged with the project name.
|
||||
# Identical in all repos; the tag comes from script/projectname.
|
||||
# Generic: needs no adaptation.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/fmt: format all files (writes).
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Must match the pin in script/bootstrap.
|
||||
NODE_VERSION="22.17.0"
|
||||
|
||||
# script/bootstrap installs node and yarn under nvm and leaves neither
|
||||
# on the PATH of the shell that called it, so resolve the pinned
|
||||
# toolchain here the way bootstrap's own install step does. nvm is a
|
||||
# bash script, hence the subshell.
|
||||
run_yarn() {
|
||||
if command -v yarn >/dev/null 2>&1; then
|
||||
yarn "$@"
|
||||
return
|
||||
fi
|
||||
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
echo "fmt: no yarn; run script/bootstrap first" >&2
|
||||
exit 1
|
||||
fi
|
||||
bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
|
||||
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
gofmt -s -w .
|
||||
goimports -w .
|
||||
# Pinned prettier reads settings from .prettierrc (tabWidth 4,
|
||||
# proseWrap always); .prettierignore keeps alpine.min.js untouched.
|
||||
# Globs are quoted so prettier expands them, not the shell.
|
||||
run_yarn run prettier --write 'static/js/*.js' '**/*.md'
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/fmt-check: check formatting (read-only). Same scope as
|
||||
# script/fmt, but fails instead of writing.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
if [ -n "$(gofmt -l .)" ]; then
|
||||
echo "Files not formatted:"
|
||||
gofmt -l .
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/install-precommit: install the git pre-commit hook that runs
|
||||
# script/precommit. Our own extension to scripts-to-rule-them-all.
|
||||
# Generic: needs no adaptation.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
|
||||
chmod +x .git/hooks/pre-commit
|
||||
echo "pre-commit hook installed: runs script/precommit"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/lint: run golangci-lint. The linter is never installed on the
|
||||
# host; it runs only inside Docker, from the pinned image in
|
||||
# Dockerfile.lint, so every run uses the same linter version everywhere.
|
||||
# Linting is a build step there, so a successful build is a clean lint.
|
||||
#
|
||||
# GATE_RUN differs every run so the lint layer always executes; a cached
|
||||
# build would otherwise exit 0 in under a second having linted nothing.
|
||||
# --output=type=cacheonly discards the image and keeps only build cache,
|
||||
# so no tagged image is left behind.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build \
|
||||
--build-arg GATE_RUN="$(date +%s)-$$" \
|
||||
--output=type=cacheonly \
|
||||
-f Dockerfile.lint \
|
||||
.
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/precommit: run by the git pre-commit hook; fails the commit if
|
||||
# checks fail. Our own extension to scripts-to-rule-them-all. Go repo
|
||||
# extras: go mod tidy must not change go.mod/go.sum.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
go mod tidy
|
||||
git diff --exit-code -- go.mod go.sum || {
|
||||
echo "precommit: go mod tidy changed go.mod/go.sum;" \
|
||||
"stage the changes and retry" >&2
|
||||
exit 1
|
||||
}
|
||||
"$SCRIPT_DIR/check"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/projectname: output the name of this project. Our own
|
||||
# extension to scripts-to-rule-them-all. Other scripts that need the
|
||||
# name (e.g. script/docker) call this, so they can stay identical
|
||||
# across all repos.
|
||||
set -eu
|
||||
|
||||
main() {
|
||||
echo "upaas"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/setup: set up the repo for development after a fresh clone:
|
||||
# installs dependencies (script/bootstrap) and the git pre-commit hook.
|
||||
# Add any repo-specific initialization (db init, .env template) here.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
|
||||
main() {
|
||||
"$SCRIPT_DIR/bootstrap"
|
||||
"$SCRIPT_DIR/install-precommit"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/test: run the test suite.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
go test -v -race -cover -timeout 30s ./...
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Vendored
+3
-3044
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user