Compare commits
1 Commits
main
...
schema-con
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e6542badf |
@@ -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
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
name: Check
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4, 2024-10-13
|
|
||||||
|
|
||||||
- name: Build (runs make check inside Dockerfile)
|
|
||||||
run: script/cibuild
|
|
||||||
31
.gitignore
vendored
31
.gitignore
vendored
@@ -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
|
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
version: "2"
|
version: "2"
|
||||||
|
|
||||||
# Config schema uses the golangci-lint v2 layout (settings live under
|
|
||||||
# linters.settings, not top-level linters-settings) so that the
|
|
||||||
# thresholds below are actually applied by golangci-lint >= v2.
|
|
||||||
|
|
||||||
run:
|
run:
|
||||||
timeout: 5m
|
timeout: 5m
|
||||||
modules-download-mode: readonly
|
modules-download-mode: readonly
|
||||||
@@ -18,7 +14,8 @@ linters:
|
|||||||
- wsl # Deprecated, replaced by wsl_v5
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
- wrapcheck # Too verbose for internal packages
|
- wrapcheck # Too verbose for internal packages
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
settings:
|
|
||||||
|
linters-settings:
|
||||||
lll:
|
lll:
|
||||||
line-length: 88
|
line-length: 88
|
||||||
funlen:
|
funlen:
|
||||||
@@ -30,5 +27,6 @@ linters:
|
|||||||
threshold: 100
|
threshold: 100
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
|
exclude-use-default: false
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|||||||
180
BUGS.md
Normal file
180
BUGS.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# Bugs in µPaaS
|
||||||
|
|
||||||
|
## 1. Potential Race Condition in Log Writing
|
||||||
|
|
||||||
|
### Description
|
||||||
|
In the deployment service, when a deployment fails, the `failDeployment` function calls `writeLogsToFile` which may be called concurrently with the async log writer's flush operations. This could lead to partial or corrupted log files.
|
||||||
|
|
||||||
|
### Location
|
||||||
|
`internal/service/deploy/deploy.go:1169` in `failDeployment` function
|
||||||
|
|
||||||
|
### Proposed Fix
|
||||||
|
1. Add synchronization to ensure only one log write operation occurs at a time
|
||||||
|
2. Modify the `deploymentLogWriter` to track completion status and prevent concurrent writes
|
||||||
|
3. Add a wait mechanism in `failDeployment` to ensure any ongoing flush operations complete before writing logs to file
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Add a mutex to deploymentLogWriter
|
||||||
|
type deploymentLogWriter struct {
|
||||||
|
// existing fields...
|
||||||
|
mu sync.Mutex
|
||||||
|
writeMu sync.Mutex // Add this for file writing synchronization
|
||||||
|
done chan struct{}
|
||||||
|
flushed sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// In writeLogsToFile, ensure exclusive access
|
||||||
|
func (svc *Service) writeLogsToFile(app *models.App, deployment *models.Deployment) {
|
||||||
|
svc.writeMu.Lock() // Add this mutex to Service struct
|
||||||
|
defer svc.writeMu.Unlock()
|
||||||
|
// existing code...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Incomplete Error Handling in Container Operations
|
||||||
|
|
||||||
|
### Description
|
||||||
|
In the Docker client's `performClone` function, if `createGitContainer` fails, the SSH key file created earlier is not cleaned up, causing a potential security risk.
|
||||||
|
|
||||||
|
### Location
|
||||||
|
`internal/docker/client.go:597` in `performClone` function
|
||||||
|
|
||||||
|
### Proposed Fix
|
||||||
|
Add proper cleanup using `defer` immediately after creating the SSH key file:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// After writing SSH key file (line 578)
|
||||||
|
keyFileCreated := false
|
||||||
|
err = os.WriteFile(cfg.keyFile, []byte(cfg.sshPrivateKey), sshKeyPermissions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write SSH key: %w", err)
|
||||||
|
}
|
||||||
|
keyFileCreated = true
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if keyFileCreated {
|
||||||
|
removeErr := os.Remove(cfg.keyFile)
|
||||||
|
if removeErr != nil {
|
||||||
|
c.log.Error("failed to remove SSH key file", "error", removeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Missing Context Cancellation Check During Build
|
||||||
|
|
||||||
|
### Description
|
||||||
|
In the deployment service's `streamBuildOutput` function, long-running Docker build operations may not properly respond to context cancellation, causing deployments to hang even when cancelled.
|
||||||
|
|
||||||
|
### Location
|
||||||
|
`internal/docker/client.go:542` in `streamBuildOutput` function
|
||||||
|
|
||||||
|
### Proposed Fix
|
||||||
|
Add context checking in the scanner loop:
|
||||||
|
|
||||||
|
```go
|
||||||
|
for scanner.Scan() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
line := scanner.Bytes()
|
||||||
|
// existing code...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Inconsistent Container Removal in Error Cases
|
||||||
|
|
||||||
|
### Description
|
||||||
|
When deployment fails during container creation, the already-created container is not removed, leading to orphaned containers that consume resources.
|
||||||
|
|
||||||
|
### Location
|
||||||
|
`internal/service/deploy/deploy.go:969` in `createAndStartContainer` function
|
||||||
|
|
||||||
|
### Proposed Fix
|
||||||
|
Add cleanup of created container on start failure:
|
||||||
|
|
||||||
|
```go
|
||||||
|
containerID, err := svc.docker.CreateContainer(ctx, containerOpts)
|
||||||
|
if err != nil {
|
||||||
|
svc.notify.NotifyDeployFailed(ctx, app, deployment, err)
|
||||||
|
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create container: %w", err))
|
||||||
|
return "", fmt.Errorf("failed to create container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cleanup defer for error cases
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
// If we have a container ID but returning an error, clean it up
|
||||||
|
_ = svc.docker.RemoveContainer(context.Background(), containerID, true)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
startErr := svc.docker.StartContainer(ctx, containerID)
|
||||||
|
if startErr != nil {
|
||||||
|
svc.notify.NotifyDeployFailed(ctx, app, deployment, startErr)
|
||||||
|
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to start container: %w", startErr))
|
||||||
|
err = startErr // Set err so defer cleanup runs
|
||||||
|
return "", fmt.Errorf("failed to start container: %w", startErr)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Potential Data Race in Active Deployments Tracking
|
||||||
|
|
||||||
|
### Description
|
||||||
|
The `activeDeploys` sync.Map in the deployment service may have race conditions when multiple concurrent deployments try to access the same app's deployment state.
|
||||||
|
|
||||||
|
### Location
|
||||||
|
`internal/service/deploy/deploy.go:226` and related functions
|
||||||
|
|
||||||
|
### Proposed Fix
|
||||||
|
Add proper locking around active deploy operations:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Add a mutex for active deploy operations
|
||||||
|
type Service struct {
|
||||||
|
// existing fields...
|
||||||
|
activeDeployMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// In Deploy function
|
||||||
|
func (svc *Service) Deploy(ctx context.Context, app *models.App, webhookEventID *int64, cancelExisting bool) error {
|
||||||
|
svc.activeDeployMu.Lock()
|
||||||
|
if cancelExisting {
|
||||||
|
svc.cancelActiveDeploy(app.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to acquire per-app deployment lock
|
||||||
|
if !svc.tryLockApp(app.ID) {
|
||||||
|
svc.activeDeployMu.Unlock()
|
||||||
|
svc.log.Warn("deployment already in progress", "app", app.Name)
|
||||||
|
return ErrDeploymentInProgress
|
||||||
|
}
|
||||||
|
svc.activeDeployMu.Unlock()
|
||||||
|
|
||||||
|
defer svc.unlockApp(app.ID)
|
||||||
|
// rest of function...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Incomplete Error Propagation in Git Clone
|
||||||
|
|
||||||
|
### Description
|
||||||
|
In the Docker client's `runGitClone` function, if `ContainerLogs` fails, the error is silently ignored, which could hide important debugging information.
|
||||||
|
|
||||||
|
### Location
|
||||||
|
`internal/docker/client.go:679` in `runGitClone` function
|
||||||
|
|
||||||
|
### Proposed Fix
|
||||||
|
Handle the ContainerLogs error properly:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Always capture logs for the result
|
||||||
|
logs, logErr := c.ContainerLogs(ctx, containerID, "100")
|
||||||
|
if logErr != nil {
|
||||||
|
c.log.Warn("failed to get git clone logs", "error", logErr)
|
||||||
|
logs = "Failed to retrieve logs: " + logErr.Error()
|
||||||
|
}
|
||||||
|
```
|
||||||
68
CLAUDE.md
Normal file
68
CLAUDE.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Repository Rules
|
||||||
|
|
||||||
|
Last Updated 2026-01-08
|
||||||
|
|
||||||
|
These rules MUST be followed at all times, it is very important.
|
||||||
|
|
||||||
|
* Never use `git add -A` - add specific changes to a deliberate commit. A
|
||||||
|
commit should contain one change. After each change, make a commit with a
|
||||||
|
good one-line summary.
|
||||||
|
|
||||||
|
* NEVER modify the linter config without asking first.
|
||||||
|
|
||||||
|
* NEVER modify tests to exclude special cases or otherwise get them to pass
|
||||||
|
without asking first. In almost all cases, the code should be changed,
|
||||||
|
NOT the tests. If you think the test needs to be changed, make your case
|
||||||
|
for that and ask for permission to proceed, then stop. You need explicit
|
||||||
|
user approval to modify existing tests. (You do not need user approval
|
||||||
|
for writing NEW tests.)
|
||||||
|
|
||||||
|
* When linting, assume the linter config is CORRECT, and that each item
|
||||||
|
output by the linter is something that legitimately needs fixing in the
|
||||||
|
code.
|
||||||
|
|
||||||
|
* When running tests, use `make test`.
|
||||||
|
|
||||||
|
* Before commits, run `make check`. This runs `make lint` and `make test`
|
||||||
|
and `make check-fmt`. Any issues discovered MUST be resolved before
|
||||||
|
committing unless explicitly told otherwise.
|
||||||
|
|
||||||
|
* When fixing a bug, write a failing test for the bug FIRST. Add
|
||||||
|
appropriate logging to the test to ensure it is written correctly. Commit
|
||||||
|
that. Then go about fixing the bug until the test passes (without
|
||||||
|
modifying the test further). Then commit that.
|
||||||
|
|
||||||
|
* When adding a new feature, do the same - implement a test first (TDD). It
|
||||||
|
doesn't have to be super complex. Commit the test, then commit the
|
||||||
|
feature.
|
||||||
|
|
||||||
|
* When adding a new feature, use a feature branch. When the feature is
|
||||||
|
completely finished and the code is up to standards (passes `make check`)
|
||||||
|
then and only then can the feature branch be merged into `main` and the
|
||||||
|
branch deleted.
|
||||||
|
|
||||||
|
* Write godoc documentation comments for all exported types and functions as
|
||||||
|
you go along.
|
||||||
|
|
||||||
|
* ALWAYS be consistent in naming. If you name something one thing in one
|
||||||
|
place, name it the EXACT SAME THING in another place.
|
||||||
|
|
||||||
|
* Be descriptive and specific in naming. `wl` is bad;
|
||||||
|
`SourceHostWhitelist` is good. `ConnsPerHost` is bad;
|
||||||
|
`MaxConnectionsPerHost` is good.
|
||||||
|
|
||||||
|
* This is not prototype or teaching code - this is designed for production.
|
||||||
|
Any security issues (such as denial of service) or other web
|
||||||
|
vulnerabilities are P1 bugs and must be added to TODO.md at the top.
|
||||||
|
|
||||||
|
* As this is production code, no stubbing of implementations unless
|
||||||
|
specifically instructed. We need working implementations.
|
||||||
|
|
||||||
|
* Avoid vendoring deps unless specifically instructed to. NEVER commit
|
||||||
|
the vendor directory, NEVER commit compiled binaries. If these
|
||||||
|
directories or files exist, add them to .gitignore (and commit the
|
||||||
|
.gitignore) if they are not already in there. Keep the entire git
|
||||||
|
repository (with history) small - under 20MiB, unless you specifically
|
||||||
|
must commit larger files (e.g. test fixture example media files). Only
|
||||||
|
OUR source code and immediately supporting files (such as test examples)
|
||||||
|
goes into the repo/history.
|
||||||
33
Dockerfile
33
Dockerfile
@@ -1,37 +1,26 @@
|
|||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Build stage
|
||||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
FROM golang:1.25-alpine AS builder
|
||||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN make fmt-check
|
|
||||||
RUN make lint
|
|
||||||
|
|
||||||
# Build stage — tests and compilation
|
|
||||||
# golang:1.25-alpine
|
|
||||||
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
|
|
||||||
|
|
||||||
# Force BuildKit to run the lint stage by creating a stage dependency
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
|
||||||
|
|
||||||
RUN apk add --no-cache git make gcc musl-dev
|
RUN apk add --no-cache git make gcc musl-dev
|
||||||
|
|
||||||
|
# Install golangci-lint v2
|
||||||
|
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||||
|
RUN go install golang.org/x/tools/cmd/goimports@latest
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN make test
|
# Run all checks - build fails if any check fails
|
||||||
|
RUN make check
|
||||||
|
|
||||||
|
# Build the binary
|
||||||
RUN make build
|
RUN make build
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
# alpine:3.19
|
FROM alpine:3.19
|
||||||
FROM alpine@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
|
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata git openssh-client docker-cli
|
RUN apk add --no-cache ca-certificates tzdata git openssh-client docker-cli
|
||||||
|
|
||||||
|
|||||||
35
Makefile
35
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: all bootstrap setup build lint fmt fmt-check test check clean docker hooks
|
.PHONY: all build lint fmt test check clean
|
||||||
|
|
||||||
BINARY := upaasd
|
BINARY := upaasd
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
@@ -7,37 +7,32 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
|||||||
|
|
||||||
all: check build
|
all: check build
|
||||||
|
|
||||||
bootstrap:
|
|
||||||
@script/bootstrap
|
|
||||||
|
|
||||||
setup:
|
|
||||||
@script/setup
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/upaasd
|
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/upaasd
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@script/lint
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
@script/fmt
|
gofmt -s -w .
|
||||||
|
goimports -w .
|
||||||
fmt-check:
|
npx prettier --write --tab-width 4 static/js/*.js
|
||||||
@script/fmt-check
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
go test -v -race -cover ./...
|
||||||
|
|
||||||
# Check runs all validation without making changes
|
# Check runs all validation without making changes
|
||||||
# Used by CI and Docker build - fails if anything is wrong
|
# Used by CI and Docker build - fails if anything is wrong
|
||||||
check:
|
check:
|
||||||
@script/check
|
@echo "==> Checking formatting..."
|
||||||
|
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
||||||
docker:
|
@echo "==> Running linter..."
|
||||||
@script/docker
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
@echo "==> Running tests..."
|
||||||
hooks:
|
go test -v -race ./...
|
||||||
@script/install-precommit
|
@echo "==> Building..."
|
||||||
|
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/upaasd
|
||||||
|
@echo "==> All checks passed!"
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin/
|
rm -rf bin/
|
||||||
|
|||||||
78
README.md
78
README.md
@@ -1,15 +1,14 @@
|
|||||||
# µPaaS by [@sneak](https://sneak.berlin)
|
# µPaaS by [@sneak](https://sneak.berlin)
|
||||||
|
|
||||||
A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via webhooks from Gitea, GitHub, or GitLab.
|
A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via Gitea webhooks.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Single admin user with argon2id password hashing
|
- Single admin user with argon2id password hashing
|
||||||
- Per-app SSH keypairs for read-only deploy keys
|
- Per-app SSH keypairs for read-only deploy keys
|
||||||
- Per-app UUID-based webhook URLs with auto-detection of Gitea, GitHub, and GitLab
|
- Per-app UUID-based webhook URLs for Gitea integration
|
||||||
- Branch filtering - only deploy on configured branch changes
|
- Branch filtering - only deploy on configured branch changes
|
||||||
- Environment variables, labels, and volume mounts per app
|
- Environment variables, labels, and volume mounts per app
|
||||||
- CPU and memory resource limits per app
|
|
||||||
- Docker builds via socket access
|
- Docker builds via socket access
|
||||||
- Notifications via ntfy and Slack-compatible webhooks
|
- Notifications via ntfy and Slack-compatible webhooks
|
||||||
- Simple server-rendered UI with Tailwind CSS
|
- Simple server-rendered UI with Tailwind CSS
|
||||||
@@ -20,7 +19,7 @@ A simple self-hosted PaaS that auto-deploys Docker containers from Git repositor
|
|||||||
- Complex CI pipelines
|
- Complex CI pipelines
|
||||||
- Multiple container orchestration
|
- Multiple container orchestration
|
||||||
- SPA/API-first design
|
- SPA/API-first design
|
||||||
- Support for non-push webhook events (e.g. issues, merge requests)
|
- Support for non-Gitea webhooks
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ upaas/
|
|||||||
│ │ ├── auth/ # Authentication service
|
│ │ ├── auth/ # Authentication service
|
||||||
│ │ ├── deploy/ # Deployment orchestration
|
│ │ ├── deploy/ # Deployment orchestration
|
||||||
│ │ ├── notify/ # Notifications (ntfy, Slack)
|
│ │ ├── notify/ # Notifications (ntfy, Slack)
|
||||||
│ │ └── webhook/ # Webhook processing (Gitea, GitHub, GitLab)
|
│ │ └── webhook/ # Gitea webhook processing
|
||||||
│ └── ssh/ # SSH key generation
|
│ └── ssh/ # SSH key generation
|
||||||
├── static/ # Embedded CSS/JS assets
|
├── static/ # Embedded CSS/JS assets
|
||||||
└── templates/ # Embedded HTML templates
|
└── templates/ # Embedded HTML templates
|
||||||
@@ -100,31 +99,6 @@ chi Router ──► Middleware Stack ──► Handler
|
|||||||
- **Async deployments**: Webhook triggers deploy via goroutine with `context.WithoutCancel()`
|
- **Async deployments**: Webhook triggers deploy via goroutine with `context.WithoutCancel()`
|
||||||
- **Embedded assets**: Templates and static files embedded via `//go:embed`
|
- **Embedded assets**: Templates and static files embedded via `//go:embed`
|
||||||
|
|
||||||
## Entrypoints
|
|
||||||
|
|
||||||
This repository adheres to the
|
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
|
||||||
standard: normalized scripts in `script/` are the entrypoints for the
|
|
||||||
development workflow, and the Makefile targets are thin shims that call
|
|
||||||
them. We provide:
|
|
||||||
|
|
||||||
- `script/bootstrap` — install all dependencies (idempotent)
|
|
||||||
- `script/setup` — make a fresh clone ready for development
|
|
||||||
(bootstrap, then install-precommit)
|
|
||||||
- `script/projectname` — output the project name ("upaas")
|
|
||||||
- `script/test` — run the test suite
|
|
||||||
- `script/lint` — run golangci-lint
|
|
||||||
- `script/fmt` — format all code (writes)
|
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
|
||||||
- `script/check` — run test, lint, and fmt-check
|
|
||||||
- `script/docker` — build the Docker image tagged via `script/projectname`
|
|
||||||
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
|
|
||||||
runs the checks, so a green build implies a green repo)
|
|
||||||
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
|
||||||
`script/check`)
|
|
||||||
- `script/install-precommit` — install the git pre-commit hook that
|
|
||||||
runs `script/precommit`
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -136,16 +110,11 @@ them. We provide:
|
|||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make bootstrap # Install all dependencies (idempotent)
|
|
||||||
make setup # Bootstrap + install git pre-commit hook
|
|
||||||
make fmt # Format code
|
make fmt # Format code
|
||||||
make fmt-check # Check formatting (read-only, fails if unformatted)
|
|
||||||
make lint # Run comprehensive linting
|
make lint # Run comprehensive linting
|
||||||
make test # Run tests with race detection (30s timeout)
|
make test # Run tests with race detection
|
||||||
make check # Verify everything passes (test, lint, fmt-check)
|
make check # Verify everything passes (lint, test, build, format)
|
||||||
make build # Build binary
|
make build # Build binary
|
||||||
make docker # Build Docker image
|
|
||||||
make hooks # Install pre-commit hook (runs script/precommit)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Commit Requirements
|
### Commit Requirements
|
||||||
@@ -188,8 +157,8 @@ Environment variables:
|
|||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `PORT` | HTTP listen port | 8080 |
|
| `PORT` | HTTP listen port | 8080 |
|
||||||
| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | `./data` (local dev only — use absolute path for Docker) |
|
| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | ./data |
|
||||||
| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | *(none — must be set to an absolute path)* |
|
| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | same as DATA_DIR |
|
||||||
| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock |
|
| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock |
|
||||||
| `DEBUG` | Enable debug logging | false |
|
| `DEBUG` | Enable debug logging | false |
|
||||||
| `SENTRY_DSN` | Sentry error reporting DSN | "" |
|
| `SENTRY_DSN` | Sentry error reporting DSN | "" |
|
||||||
@@ -207,35 +176,8 @@ docker run -d \
|
|||||||
upaas
|
upaas
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Compose
|
**Important**: When running µPaaS inside a container, set `UPAAS_HOST_DATA_DIR` to the host path
|
||||||
|
that maps to `UPAAS_DATA_DIR`. This is required for Docker bind mounts during builds to work correctly.
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
upaas:
|
|
||||||
build: .
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- ${HOST_DATA_DIR}:/var/lib/upaas
|
|
||||||
environment:
|
|
||||||
- UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR}
|
|
||||||
# Optional: uncomment to enable debug logging
|
|
||||||
# - DEBUG=true
|
|
||||||
# Optional: Sentry error reporting
|
|
||||||
# - SENTRY_DSN=https://...
|
|
||||||
# Optional: Prometheus metrics auth
|
|
||||||
# - METRICS_USERNAME=prometheus
|
|
||||||
# - METRICS_PASSWORD=secret
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the host before running
|
|
||||||
`docker compose up`. This value is bind-mounted into the container and passed as `UPAAS_HOST_DATA_DIR`
|
|
||||||
so that Docker bind mounts during builds resolve correctly. Relative paths (e.g. `./data`) will break
|
|
||||||
container builds because the Docker daemon resolves paths relative to the host, not the container.
|
|
||||||
|
|
||||||
Example: `HOST_DATA_DIR=/srv/upaas/data docker compose up -d`
|
|
||||||
|
|
||||||
Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`.
|
Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`.
|
||||||
|
|
||||||
|
|||||||
408
REPO_POLICIES.md
408
REPO_POLICIES.md
@@ -1,408 +0,0 @@
|
|||||||
---
|
|
||||||
title: Repository Policies
|
|
||||||
last_modified: 2026-07-06
|
|
||||||
---
|
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
|
||||||
style conventions are in separate documents:
|
|
||||||
|
|
||||||
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
|
||||||
(general, bash, Docker)
|
|
||||||
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
|
||||||
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
|
||||||
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
|
||||||
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- Cross-project documentation (such as this file) must include
|
|
||||||
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
|
|
||||||
with the authoritative source as policies evolve.
|
|
||||||
|
|
||||||
- **ALL external references must be pinned by cryptographic hash.** This
|
|
||||||
includes Docker base images, Go modules, npm packages, GitHub Actions, and
|
|
||||||
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
|
|
||||||
`:3.21`, etc.) are server-mutable and therefore remote code execution
|
|
||||||
vulnerabilities. The ONLY acceptable way to reference an external dependency
|
|
||||||
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
|
|
||||||
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
|
|
||||||
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
|
|
||||||
etc. Instead, download a specific release archive from GitHub, verify its hash
|
|
||||||
(hardcoded in the Dockerfile or script), and only then install. Unverified
|
|
||||||
install scripts are arbitrary remote code execution. This is the single most
|
|
||||||
important rule in this document. Double-check every external reference in
|
|
||||||
every file before committing. There are zero exceptions to this rule.
|
|
||||||
|
|
||||||
- Every repo with software must have a root `Makefile` with these targets:
|
|
||||||
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
|
|
||||||
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
|
|
||||||
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
|
|
||||||
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
|
||||||
|
|
||||||
- Repos follow the
|
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
|
||||||
pattern: the implementation of each Makefile target lives in an executable
|
|
||||||
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
|
|
||||||
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
|
|
||||||
`script/docker`), and the Makefile targets are thin shims that call them. The
|
|
||||||
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
|
|
||||||
minimal containers (e.g. alpine images have no bash); locate the repo root
|
|
||||||
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
|
|
||||||
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
|
|
||||||
for development after a fresh clone: runs `bootstrap`, then
|
|
||||||
`install-precommit`, plus any repo-specific initialization), `test`, and
|
|
||||||
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
|
||||||
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
|
||||||
(detected in that order; apt runs noninteractive). For node it uses the
|
|
||||||
installed node if present; otherwise it installs a PINNED node version via
|
|
||||||
nvm, first installing nvm itself if missing — from a hash-verified GitHub
|
|
||||||
release archive (never `curl | sh`), with bash installed as an explicit
|
|
||||||
prerequisite since nvm requires bash. yarn is then pinned via
|
|
||||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
|
||||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
|
||||||
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
|
|
||||||
scripts are our own extensions to the standard: `script/check` runs
|
|
||||||
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
|
||||||
what the git pre-commit hook runs, and it calls `script/check`;
|
|
||||||
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
|
||||||
target shims to it); and `script/projectname` (literally that filename) simply
|
|
||||||
outputs the project's name. Scripts that need the name call
|
|
||||||
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
|
||||||
so those scripts stay byte-identical across all repos. Repo-type-specific
|
|
||||||
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
|
||||||
`script/precommit`, not in the hook itself. Model scripts are at
|
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
|
||||||
must document the provided scripts in an **Entrypoints** section (see the
|
|
||||||
README requirements below).
|
|
||||||
|
|
||||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
|
||||||
instead of invoking the underlying tools directly. The Makefile is the single
|
|
||||||
source of truth for how these operations are run.
|
|
||||||
|
|
||||||
- The Makefile is authoritative documentation for how the repo is used. Beyond
|
|
||||||
the required targets above, it should have targets for every common operation:
|
|
||||||
running a local development server (`make run`, `make dev`), re-initializing
|
|
||||||
or migrating the database (`make db-reset`, `make migrate`), building
|
|
||||||
artifacts (`make build`), generating code, seeding data, or anything else a
|
|
||||||
developer would do regularly. If someone checks out the repo and types
|
|
||||||
`make<tab>`, they should see every meaningful operation available. A new
|
|
||||||
contributor should be able to understand the entire development workflow by
|
|
||||||
reading the Makefile.
|
|
||||||
|
|
||||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
|
||||||
as a build step so the build fails if the branch is not green. For non-server
|
|
||||||
repos, the Dockerfile should bring up a development environment and run
|
|
||||||
`make check`. For server repos, `make check` should run as an early build
|
|
||||||
stage before the final image is assembled. Dockerfiles install development
|
|
||||||
prerequisites by running `script/bootstrap` rather than duplicating installs
|
|
||||||
inline; COPY `script/` and the dependency manifests (`package.json` +
|
|
||||||
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
|
||||||
layer stays cached until dependencies change.
|
|
||||||
|
|
||||||
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
|
||||||
repos use a multistage build where linting runs in an independent stage based
|
|
||||||
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
|
||||||
`make fmt-check` and `make lint` before the full build begins. The build stage
|
|
||||||
then declares an explicit dependency on the lint stage via
|
|
||||||
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
|
|
||||||
linting before proceeding to compilation and tests. This ensures lint failures
|
|
||||||
surface in seconds rather than minutes, without blocking on dependency
|
|
||||||
download or compilation in the build stage.
|
|
||||||
|
|
||||||
The standard pattern for a Go repo Dockerfile is:
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# Lint stage — fast feedback on formatting and lint issues
|
|
||||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
|
||||||
FROM golangci/golangci-lint@sha256:... AS lint
|
|
||||||
WORKDIR /src
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY . .
|
|
||||||
RUN make fmt-check
|
|
||||||
RUN make lint
|
|
||||||
|
|
||||||
# Build stage
|
|
||||||
# golang:1.x-alpine, YYYY-MM-DD
|
|
||||||
FROM golang@sha256:... AS builder
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
# Force BuildKit to run the lint stage before proceeding
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY . .
|
|
||||||
RUN make test
|
|
||||||
|
|
||||||
ARG VERSION=dev
|
|
||||||
RUN CGO_ENABLED=0 go build -trimpath \
|
|
||||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
|
||||||
-o /app ./cmd/app/
|
|
||||||
|
|
||||||
# Runtime stage
|
|
||||||
FROM alpine@sha256:...
|
|
||||||
COPY --from=builder /app /usr/local/bin/app
|
|
||||||
ENTRYPOINT ["app"]
|
|
||||||
```
|
|
||||||
|
|
||||||
Key points:
|
|
||||||
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
|
||||||
includes both Go and the linter), so there is no need to install the
|
|
||||||
linter separately.
|
|
||||||
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
|
||||||
a stage dependency. BuildKit runs stages in parallel by default; without
|
|
||||||
this line, the build stage would not wait for lint to finish and a lint
|
|
||||||
failure might not fail the overall build.
|
|
||||||
- If the project uses `//go:embed` directives that reference build artifacts
|
|
||||||
(e.g. a web frontend compiled in a separate stage), the lint stage must
|
|
||||||
create placeholder files so the embed directives resolve. Example:
|
|
||||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
|
||||||
The lint stage should not depend on the actual build output — it exists to
|
|
||||||
fail fast.
|
|
||||||
- If the project requires CGO or system libraries for linting (e.g.
|
|
||||||
`vips-dev`), install them in the lint stage with `apk add`.
|
|
||||||
- The build stage runs `make test` after compilation setup. Tests run in the
|
|
||||||
build stage, not the lint stage, because they may require compiled
|
|
||||||
artifacts or heavier dependencies.
|
|
||||||
|
|
||||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
|
||||||
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
|
||||||
Dockerfile already runs `make check`, a successful build implies all checks
|
|
||||||
pass.
|
|
||||||
|
|
||||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
|
||||||
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
|
||||||
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
|
||||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
|
||||||
|
|
||||||
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
|
|
||||||
testing is not possible in the repo, `script/precommit` may skip `script/test`
|
|
||||||
and run only `script/lint` and `script/fmt-check`. The hook is installed by
|
|
||||||
`script/install-precommit`; the Makefile must provide a `make hooks` target
|
|
||||||
that shims to it.
|
|
||||||
|
|
||||||
- All repos with software must have tests that run via the platform-standard
|
|
||||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
|
||||||
tests exist yet, add the most minimal test possible — e.g. importing the
|
|
||||||
module under test to verify it compiles/parses. There is no excuse for
|
|
||||||
`make test` to be a no-op.
|
|
||||||
|
|
||||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
|
||||||
Makefile.
|
|
||||||
|
|
||||||
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
|
||||||
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
|
||||||
show full output. This keeps CI logs and `docker build` output clean on
|
|
||||||
success (just package/suite summaries) while providing full diagnostic detail
|
|
||||||
on failure (every test case, every assertion). The general shell pattern:
|
|
||||||
|
|
||||||
```makefile
|
|
||||||
test:
|
|
||||||
@<test-command> || \
|
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
|
||||||
<test-command-with-v>; exit 1; }
|
|
||||||
```
|
|
||||||
|
|
||||||
Go example:
|
|
||||||
|
|
||||||
```makefile
|
|
||||||
test:
|
|
||||||
@go test -timeout 30s -race -cover ./... || \
|
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
|
||||||
go test -timeout 30s -race -v ./...; exit 1; }
|
|
||||||
```
|
|
||||||
|
|
||||||
Python example:
|
|
||||||
|
|
||||||
```makefile
|
|
||||||
test:
|
|
||||||
@python -m pytest || \
|
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
|
||||||
python -m pytest -v; exit 1; }
|
|
||||||
```
|
|
||||||
|
|
||||||
The `exit 1` ensures the target always fails after a rerun — the first run
|
|
||||||
already proved the tests are broken, so the build must not pass even if a
|
|
||||||
flaky test happens to succeed on the second attempt. The rerun exists solely
|
|
||||||
for diagnostic output.
|
|
||||||
|
|
||||||
- Docker builds must complete in under 5 minutes.
|
|
||||||
|
|
||||||
- `make check` must not modify any files in the repo. Tests may use temporary
|
|
||||||
directories.
|
|
||||||
|
|
||||||
- `main` must always pass `make check`, no exceptions.
|
|
||||||
|
|
||||||
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
|
||||||
must be in `.gitignore`. No exceptions.
|
|
||||||
|
|
||||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
|
||||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
|
||||||
Fetch the standard `.gitignore` from
|
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
|
||||||
a new repo.
|
|
||||||
|
|
||||||
- **No build artifacts in version control.** Code-derived data (compiled
|
|
||||||
bundles, minified output, generated assets) must never be committed to the
|
|
||||||
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
|
||||||
should generate these at build time. Notable exception: Go protobuf generated
|
|
||||||
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
|
||||||
downloads code but does not execute code generation.
|
|
||||||
|
|
||||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
|
||||||
|
|
||||||
- Never force-push to `main`.
|
|
||||||
|
|
||||||
- Make all changes on a feature branch. You can do whatever you want on a
|
|
||||||
feature branch.
|
|
||||||
|
|
||||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
|
||||||
manually by the user. Fetch from
|
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
|
||||||
|
|
||||||
- When pinning images or packages by hash, add a comment above the reference
|
|
||||||
with the version and date (YYYY-MM-DD).
|
|
||||||
|
|
||||||
- Use `yarn`, not `npm`.
|
|
||||||
|
|
||||||
- Write all dates as YYYY-MM-DD (ISO 8601).
|
|
||||||
|
|
||||||
- Simple projects should be configured with environment variables.
|
|
||||||
|
|
||||||
- Dockerized web services listen on port 8080 by default, overridable with
|
|
||||||
`PORT`.
|
|
||||||
|
|
||||||
- **HTTP/web services must be hardened for production internet exposure before
|
|
||||||
tagging 1.0.** This means full compliance with security best practices
|
|
||||||
including, without limitation, all of the following:
|
|
||||||
- **Security headers** on every response:
|
|
||||||
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
|
|
||||||
and `includeSubDomains`.
|
|
||||||
- `Content-Security-Policy` (CSP) with a restrictive default policy
|
|
||||||
(`default-src 'self'` as a baseline, tightened per-resource as
|
|
||||||
needed). Never use `unsafe-inline` or `unsafe-eval` unless
|
|
||||||
unavoidable, and document the reason.
|
|
||||||
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
|
|
||||||
Prefer the `frame-ancestors` CSP directive as the primary control.
|
|
||||||
- `X-Content-Type-Options: nosniff`.
|
|
||||||
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
|
|
||||||
- `Permissions-Policy` restricting access to browser features the
|
|
||||||
application does not use (camera, microphone, geolocation, etc.).
|
|
||||||
- **Request and response limits:**
|
|
||||||
- Maximum request body size enforced on all endpoints (e.g. Go
|
|
||||||
`http.MaxBytesReader`). Choose a sane default per-route; never accept
|
|
||||||
unbounded input.
|
|
||||||
- Maximum response body size where applicable (e.g. paginated APIs).
|
|
||||||
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
|
|
||||||
against slowloris attacks.
|
|
||||||
- `WriteTimeout` on the `http.Server`.
|
|
||||||
- `IdleTimeout` on the `http.Server`.
|
|
||||||
- Per-handler execution time limits via `context.WithTimeout` or
|
|
||||||
chi/stdlib `middleware.Timeout`.
|
|
||||||
- **Authentication and session security:**
|
|
||||||
- Rate limiting on password-based authentication endpoints. API keys are
|
|
||||||
high-entropy and not susceptible to brute force, so they are exempt.
|
|
||||||
- CSRF tokens on all state-mutating HTML forms. API endpoints
|
|
||||||
authenticated via `Authorization` header (Bearer token, API key) are
|
|
||||||
exempt because the browser does not attach these automatically.
|
|
||||||
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
|
|
||||||
MD5, or SHA.
|
|
||||||
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
|
|
||||||
`Strict`) attributes.
|
|
||||||
- **Reverse proxy awareness:**
|
|
||||||
- True client IP detection when behind a reverse proxy
|
|
||||||
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
|
||||||
forwarded headers only from a configured set of trusted proxy
|
|
||||||
addresses — never trust `X-Forwarded-For` unconditionally.
|
|
||||||
- **CORS:**
|
|
||||||
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
|
|
||||||
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
|
|
||||||
only for public, unauthenticated read-only APIs.
|
|
||||||
- **Error handling:**
|
|
||||||
- Internal errors must never leak stack traces, SQL queries, file paths,
|
|
||||||
or other implementation details to the client. Return generic error
|
|
||||||
messages in production; detailed errors only when `DEBUG` is enabled.
|
|
||||||
- **TLS:**
|
|
||||||
- Services never terminate TLS directly. They are always deployed behind
|
|
||||||
a TLS-terminating reverse proxy. The service itself listens on plain
|
|
||||||
HTTP. However, HSTS headers and `Secure` cookie flags must still be
|
|
||||||
set by the application so that the browser enforces HTTPS end-to-end.
|
|
||||||
|
|
||||||
This list is non-exhaustive. Apply defense-in-depth: if a standard security
|
|
||||||
hardening measure exists for HTTP services and is not listed here, it is
|
|
||||||
still expected. When in doubt, harden.
|
|
||||||
|
|
||||||
- `README.md` is the primary documentation. Required sections:
|
|
||||||
- **Description**: First line must include the project name, purpose,
|
|
||||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
|
||||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
|
||||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
|
||||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
|
||||||
- **Entrypoints**: Opens by stating that the repo adheres to the
|
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
|
||||||
standard (with that link), then documents each provided `script/`
|
|
||||||
entrypoint and its purpose.
|
|
||||||
- **Rationale**: Why does this exist?
|
|
||||||
- **Design**: How is the program structured?
|
|
||||||
- **TODO**: Update meticulously, even between commits. When planning, put
|
|
||||||
the todo list in the README so a new agent can pick up where the last one
|
|
||||||
left off.
|
|
||||||
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
|
||||||
`LICENSE` file in the repo root and a License section in the README.
|
|
||||||
- **Author**: [@sneak](https://sneak.berlin).
|
|
||||||
|
|
||||||
- First commit of a new repo should contain only `README.md`.
|
|
||||||
|
|
||||||
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
|
|
||||||
committing.
|
|
||||||
|
|
||||||
- Use SemVer.
|
|
||||||
|
|
||||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
|
||||||
the binary.
|
|
||||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
|
||||||
tracking table itself. Nothing else.
|
|
||||||
- `001_schema.sql` — the full application schema.
|
|
||||||
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
|
||||||
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
|
||||||
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
|
||||||
Never edit existing migrations after release.
|
|
||||||
|
|
||||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
|
||||||
settings.
|
|
||||||
|
|
||||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
|
||||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
|
||||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
|
||||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
|
||||||
subdirectory names:
|
|
||||||
- `bin/` — executable scripts and tools
|
|
||||||
- `cmd/` — Go command entrypoints
|
|
||||||
- `configs/` — configuration templates and examples
|
|
||||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
|
||||||
- `docs/` — documentation and markdown (README.md stays in root)
|
|
||||||
- `internal/` — Go internal packages
|
|
||||||
- `internal/db/migrations/` — database migrations
|
|
||||||
- `pkg/` — Go library packages
|
|
||||||
- `share/` — systemd units, data files
|
|
||||||
- `static/` — static assets (images, fonts, etc.)
|
|
||||||
- `web/` — web frontend source
|
|
||||||
|
|
||||||
- When setting up a new repo, files from the `prompts` repo may be used as
|
|
||||||
templates. Fetch them from
|
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
|
||||||
|
|
||||||
- New repos must contain at minimum:
|
|
||||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
|
||||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
|
||||||
- `Makefile`
|
|
||||||
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
|
|
||||||
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
|
|
||||||
`install-precommit`)
|
|
||||||
- `Dockerfile`, `.dockerignore`
|
|
||||||
- `.gitea/workflows/check.yml`
|
|
||||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
|
||||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
|
||||||
- Python: `pyproject.toml`
|
|
||||||
49
TODO.md
49
TODO.md
@@ -1,49 +0,0 @@
|
|||||||
# Workflow
|
|
||||||
|
|
||||||
* branch (from `main`)
|
|
||||||
* do the work in Next Step
|
|
||||||
* move Next Step to the top of Completed Steps
|
|
||||||
* move the top item of Future Steps into Next Step
|
|
||||||
* commit (`TODO.md` changes in the same commit as the work)
|
|
||||||
* merge to `main` if the branch is not protected, otherwise open a PR
|
|
||||||
* push
|
|
||||||
|
|
||||||
# Status
|
|
||||||
|
|
||||||
1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. `make check`
|
|
||||||
is green as of the golangci-lint v2.12.2 update.
|
|
||||||
|
|
||||||
# Next Step
|
|
||||||
|
|
||||||
Confirm `.gitea/workflows/check.yml` gates merges on `make check` so
|
|
||||||
main cannot regress.
|
|
||||||
|
|
||||||
# Completed Steps
|
|
||||||
|
|
||||||
- 2026-08-07: Updated golangci-lint to v2.12.2 (canonical
|
|
||||||
`.golangci.yml`, `Dockerfile` lint stage pin, `script/bootstrap`
|
|
||||||
release-archive pins) and fixed all resulting lint findings (noctx,
|
|
||||||
gosec, goconst, lll, dupl, nolintlint); `make check` green.
|
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
|
||||||
Makefile shims, README Entrypoints section
|
|
||||||
- 2026-03-11: Monolithic env var editing with bulk save (#158).
|
|
||||||
- 2026-03-10: Webhook event history UI page (#164); added missing
|
|
||||||
Makefile docker and hooks targets plus test timeout (#159);
|
|
||||||
notification settings passed from create form (#160).
|
|
||||||
- 2026-03-03: REPO_POLICIES compliance file set added (#155).
|
|
||||||
- 2026-03-01: Module path changed to sneak.berlin/go/upaas (#143);
|
|
||||||
Dockerfile split into lint and build stages with forced lint
|
|
||||||
execution (#152, #154).
|
|
||||||
- 2026-02-26: 1.0.0 tagged; dashboard CSRFField crash fixed (#146).
|
|
||||||
- 1.0 audit bug fixes (#120-#125): deferred rollback on commit error,
|
|
||||||
deployment log size cap, error path rendering, docker-compose bind
|
|
||||||
mount, domain type refactor.
|
|
||||||
- CI simplified to docker build only (#130).
|
|
||||||
- 2025-12-29 onward: core PaaS built out: deploys with real-time build
|
|
||||||
log streaming, container start/stop/restart and logs, TCP/UDP port
|
|
||||||
mapping, Alpine.js UI, Slack notifications, ULID app IDs, session
|
|
||||||
handling.
|
|
||||||
|
|
||||||
# Future Steps
|
|
||||||
|
|
||||||
- Resume feature work only after main is green.
|
|
||||||
@@ -4,20 +4,20 @@ package main
|
|||||||
import (
|
import (
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
"sneak.berlin/go/upaas/internal/docker"
|
"git.eeqj.de/sneak/upaas/internal/docker"
|
||||||
"sneak.berlin/go/upaas/internal/globals"
|
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||||
"sneak.berlin/go/upaas/internal/handlers"
|
"git.eeqj.de/sneak/upaas/internal/handlers"
|
||||||
"sneak.berlin/go/upaas/internal/healthcheck"
|
"git.eeqj.de/sneak/upaas/internal/healthcheck"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/middleware"
|
"git.eeqj.de/sneak/upaas/internal/middleware"
|
||||||
"sneak.berlin/go/upaas/internal/server"
|
"git.eeqj.de/sneak/upaas/internal/server"
|
||||||
"sneak.berlin/go/upaas/internal/service/app"
|
"git.eeqj.de/sneak/upaas/internal/service/app"
|
||||||
"sneak.berlin/go/upaas/internal/service/auth"
|
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
||||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||||
"sneak.berlin/go/upaas/internal/service/notify"
|
"git.eeqj.de/sneak/upaas/internal/service/notify"
|
||||||
"sneak.berlin/go/upaas/internal/service/webhook"
|
"git.eeqj.de/sneak/upaas/internal/service/webhook"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload"
|
_ "github.com/joho/godotenv/autoload"
|
||||||
)
|
)
|
||||||
|
|||||||
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
upaas:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- upaas-data:/var/lib/upaas
|
||||||
|
# environment:
|
||||||
|
# Optional: uncomment to enable debug logging
|
||||||
|
# - DEBUG=true
|
||||||
|
# Optional: Sentry error reporting
|
||||||
|
# - SENTRY_DSN=https://...
|
||||||
|
# Optional: Prometheus metrics auth
|
||||||
|
# - METRICS_USERNAME=prometheus
|
||||||
|
# - METRICS_PASSWORD=secret
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
upaas-data:
|
||||||
4
go.mod
4
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module sneak.berlin/go/upaas
|
module git.eeqj.de/sneak/upaas
|
||||||
|
|
||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
@@ -19,7 +19,6 @@ require (
|
|||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
golang.org/x/crypto v0.46.0
|
golang.org/x/crypto v0.46.0
|
||||||
golang.org/x/time v0.12.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -75,6 +74,7 @@ require (
|
|||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
golang.org/x/time v0.12.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gotest.tools/v3 v3.5.2 // indirect
|
gotest.tools/v3 v3.5.2 // indirect
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/globals"
|
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultPort is the default HTTP server port.
|
// defaultPort is the default HTTP server port.
|
||||||
@@ -45,14 +45,13 @@ type Config struct {
|
|||||||
Port int
|
Port int
|
||||||
Debug bool
|
Debug bool
|
||||||
DataDir string
|
DataDir string
|
||||||
HostDataDir string // Host path for DataDir (Docker bind mounts in container)
|
HostDataDir string // Host path for DataDir (for Docker bind mounts when running in container)
|
||||||
DockerHost string
|
DockerHost string
|
||||||
SentryDSN string
|
SentryDSN string
|
||||||
MaintenanceMode bool
|
MaintenanceMode bool
|
||||||
MetricsUsername string
|
MetricsUsername string
|
||||||
MetricsPassword string
|
MetricsPassword string
|
||||||
SessionSecret string `json:"-"`
|
SessionSecret string
|
||||||
CORSOrigins string
|
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -103,7 +102,6 @@ func setupViper(name string) {
|
|||||||
viper.SetDefault("METRICS_USERNAME", "")
|
viper.SetDefault("METRICS_USERNAME", "")
|
||||||
viper.SetDefault("METRICS_PASSWORD", "")
|
viper.SetDefault("METRICS_PASSWORD", "")
|
||||||
viper.SetDefault("SESSION_SECRET", "")
|
viper.SetDefault("SESSION_SECRET", "")
|
||||||
viper.SetDefault("CORS_ORIGINS", "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
|
func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
|
||||||
@@ -138,7 +136,6 @@ func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
|
|||||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||||
SessionSecret: viper.GetString("SESSION_SECRET"),
|
SessionSecret: viper.GetString("SESSION_SECRET"),
|
||||||
CORSOrigins: viper.GetString("CORS_ORIGINS"),
|
|
||||||
params: params,
|
params: params,
|
||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// dataDirPermissions is the file permission for the data directory.
|
// dataDirPermissions is the file permission for the data directory.
|
||||||
@@ -178,8 +178,7 @@ func HashWebhookSecret(secret string) string {
|
|||||||
|
|
||||||
func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error {
|
func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error {
|
||||||
rows, err := d.database.QueryContext(ctx,
|
rows, err := d.database.QueryContext(ctx,
|
||||||
"SELECT id, webhook_secret FROM apps"+
|
"SELECT id, webhook_secret FROM apps WHERE webhook_secret_hash = '' AND webhook_secret != ''")
|
||||||
" WHERE webhook_secret_hash = '' AND webhook_secret != ''")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("querying apps for backfill: %w", err)
|
return fmt.Errorf("querying apps for backfill: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHashWebhookSecret(t *testing.T) {
|
func TestHashWebhookSecret(t *testing.T) {
|
||||||
|
|||||||
@@ -2,80 +2,36 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"embed"
|
"embed"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migrations/*.sql
|
//go:embed migrations/*.sql
|
||||||
var migrationsFS embed.FS
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
// bootstrapVersion is the migration that creates the schema_migrations
|
func (d *Database) migrate(ctx context.Context) error {
|
||||||
// table itself. It is applied before the normal migration loop.
|
// Create migrations table if not exists
|
||||||
const bootstrapVersion = 0
|
_, err := d.database.ExecContext(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
// ErrInvalidMigrationFilename indicates a migration filename does not follow
|
version TEXT PRIMARY KEY,
|
||||||
// the expected "<version>.sql" or "<version>_<description>.sql" pattern.
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
var ErrInvalidMigrationFilename = errors.New("invalid migration filename")
|
|
||||||
|
|
||||||
// ParseMigrationVersion extracts the numeric version prefix from a migration
|
|
||||||
// filename. Filenames must follow the pattern "<version>.sql" or
|
|
||||||
// "<version>_<description>.sql", where version is a zero-padded numeric
|
|
||||||
// string (e.g. "001", "002"). Returns the version as an integer and an
|
|
||||||
// error if the filename does not match the expected pattern.
|
|
||||||
func ParseMigrationVersion(filename string) (int, error) {
|
|
||||||
name := strings.TrimSuffix(filename, ".sql")
|
|
||||||
if name == "" || name == filename {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"%w: %q has no .sql extension or is empty",
|
|
||||||
ErrInvalidMigrationFilename, filename,
|
|
||||||
)
|
)
|
||||||
}
|
`)
|
||||||
|
|
||||||
// Split on underscore to separate version from description.
|
|
||||||
// If there's no underscore, the entire stem is the version.
|
|
||||||
versionStr, _, _ := strings.Cut(name, "_")
|
|
||||||
|
|
||||||
if versionStr == "" {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"%w: %q has empty version prefix",
|
|
||||||
ErrInvalidMigrationFilename, filename,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the version is purely numeric.
|
|
||||||
for _, ch := range versionStr {
|
|
||||||
if ch < '0' || ch > '9' {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"%w: %q version %q contains non-numeric character %q",
|
|
||||||
ErrInvalidMigrationFilename, filename, versionStr, string(ch),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
version, err := strconv.Atoi(versionStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("%w: %q: %w", ErrInvalidMigrationFilename, filename, err)
|
return fmt.Errorf("failed to create migrations table: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return version, nil
|
// Get list of migration files
|
||||||
}
|
|
||||||
|
|
||||||
// collectMigrations reads the embedded migrations directory and returns
|
|
||||||
// migration filenames sorted lexicographically.
|
|
||||||
func collectMigrations() ([]string, error) {
|
|
||||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read migrations directory: %w", err)
|
return fmt.Errorf("failed to read migrations directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var migrations []string
|
// Sort migrations by name
|
||||||
|
migrations := make([]string, 0, len(entries))
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
||||||
@@ -85,118 +41,54 @@ func collectMigrations() ([]string, error) {
|
|||||||
|
|
||||||
sort.Strings(migrations)
|
sort.Strings(migrations)
|
||||||
|
|
||||||
return migrations, nil
|
// Apply each migration
|
||||||
}
|
|
||||||
|
|
||||||
// bootstrapMigrationsTable ensures the schema_migrations table exists by
|
|
||||||
// applying 000_migration.sql if the table is missing.
|
|
||||||
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
|
||||||
var tableExists int
|
|
||||||
|
|
||||||
err := db.QueryRowContext(ctx,
|
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
|
||||||
).Scan(&tableExists)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check for migrations table: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tableExists == 0 {
|
|
||||||
return applyBootstrapMigration(ctx, db, log)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyBootstrapMigration reads and executes 000_migration.sql to create the
|
|
||||||
// schema_migrations table on a fresh database.
|
|
||||||
func applyBootstrapMigration(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
|
||||||
content, err := migrationsFS.ReadFile("migrations/000_migration.sql")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read bootstrap migration 000_migration.sql: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if log != nil {
|
|
||||||
log.Info("applying bootstrap migration", "version", bootstrapVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(ctx, string(content))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyMigrations applies all pending migrations to db. An optional logger
|
|
||||||
// may be provided for informational output; pass nil for silent operation.
|
|
||||||
// This is exported so tests can apply the real schema without the full fx
|
|
||||||
// lifecycle.
|
|
||||||
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
|
||||||
bootstrapErr := bootstrapMigrationsTable(ctx, db, log)
|
|
||||||
if bootstrapErr != nil {
|
|
||||||
return bootstrapErr
|
|
||||||
}
|
|
||||||
|
|
||||||
migrations, err := collectMigrations()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, migration := range migrations {
|
for _, migration := range migrations {
|
||||||
version, parseErr := ParseMigrationVersion(migration)
|
applied, err := d.isMigrationApplied(ctx, migration)
|
||||||
if parseErr != nil {
|
|
||||||
return parseErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already applied.
|
|
||||||
var count int
|
|
||||||
|
|
||||||
err = db.QueryRowContext(ctx,
|
|
||||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
|
||||||
version,
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check migration %s: %w", migration, err)
|
return fmt.Errorf("failed to check migration %s: %w", migration, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if count > 0 {
|
if applied {
|
||||||
if log != nil {
|
d.log.Debug("migration already applied", "migration", migration)
|
||||||
log.Debug("migration already applied", "version", version)
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply migration in a transaction.
|
err = d.applyMigration(ctx, migration)
|
||||||
applyErr := applyMigrationTx(ctx, db, migration, version)
|
if err != nil {
|
||||||
if applyErr != nil {
|
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
|
||||||
return applyErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if log != nil {
|
d.log.Info("migration applied", "migration", migration)
|
||||||
log.Info("migration applied", "version", version)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyMigrationTx reads and executes a migration file within a transaction,
|
func (d *Database) isMigrationApplied(ctx context.Context, version string) (bool, error) {
|
||||||
// recording the version in schema_migrations on success.
|
var count int
|
||||||
func applyMigrationTx(
|
|
||||||
ctx context.Context,
|
err := d.database.QueryRowContext(
|
||||||
db *sql.DB,
|
ctx,
|
||||||
filename string,
|
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
||||||
version int,
|
version,
|
||||||
) error {
|
).Scan(&count)
|
||||||
content, err := migrationsFS.ReadFile("migrations/" + filename)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read migration %s: %w", filename, err)
|
return false, fmt.Errorf("failed to query migration status: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
transaction, err := db.BeginTx(ctx, nil)
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) applyMigration(ctx context.Context, filename string) error {
|
||||||
|
content, err := migrationsFS.ReadFile("migrations/" + filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to begin transaction for migration %s: %w", filename, err)
|
return fmt.Errorf("failed to read migration file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction, err := d.database.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -205,27 +97,26 @@ func applyMigrationTx(
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Execute migration
|
||||||
_, err = transaction.ExecContext(ctx, string(content))
|
_, err = transaction.ExecContext(ctx, string(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to execute migration %s: %w", filename, err)
|
return fmt.Errorf("failed to execute migration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = transaction.ExecContext(ctx,
|
// Record migration
|
||||||
|
_, err = transaction.ExecContext(
|
||||||
|
ctx,
|
||||||
"INSERT INTO schema_migrations (version) VALUES (?)",
|
"INSERT INTO schema_migrations (version) VALUES (?)",
|
||||||
version,
|
filename,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to record migration %s: %w", filename, err)
|
return fmt.Errorf("failed to record migration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = transaction.Commit()
|
commitErr := transaction.Commit()
|
||||||
if err != nil {
|
if commitErr != nil {
|
||||||
return fmt.Errorf("failed to commit migration %s: %w", filename, err)
|
return fmt.Errorf("failed to commit migration: %w", commitErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) migrate(ctx context.Context) error {
|
|
||||||
return ApplyMigrations(ctx, d.database, d.log)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
6
internal/database/migrations/001_init_migrations.sql
Normal file
6
internal/database/migrations/001_init_migrations.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Initialize migrations table for tracking applied migrations
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
-- Initial schema for upaas
|
-- Complete schema for upaas (consolidated)
|
||||||
|
-- This represents the final state of all migrations applied
|
||||||
|
|
||||||
-- Users table (single admin user)
|
-- Users table (single admin user)
|
||||||
CREATE TABLE users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
@@ -9,7 +10,7 @@ CREATE TABLE users (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Apps table
|
-- Apps table
|
||||||
CREATE TABLE apps (
|
CREATE TABLE IF NOT EXISTS apps (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT UNIQUE NOT NULL,
|
name TEXT UNIQUE NOT NULL,
|
||||||
repo_url TEXT NOT NULL,
|
repo_url TEXT NOT NULL,
|
||||||
@@ -18,18 +19,19 @@ CREATE TABLE apps (
|
|||||||
webhook_secret TEXT NOT NULL,
|
webhook_secret TEXT NOT NULL,
|
||||||
ssh_private_key TEXT NOT NULL,
|
ssh_private_key TEXT NOT NULL,
|
||||||
ssh_public_key TEXT NOT NULL,
|
ssh_public_key TEXT NOT NULL,
|
||||||
container_id TEXT,
|
|
||||||
image_id TEXT,
|
image_id TEXT,
|
||||||
|
previous_image_id TEXT,
|
||||||
status TEXT DEFAULT 'pending',
|
status TEXT DEFAULT 'pending',
|
||||||
docker_network TEXT,
|
docker_network TEXT,
|
||||||
ntfy_topic TEXT,
|
ntfy_topic TEXT,
|
||||||
slack_webhook TEXT,
|
slack_webhook TEXT,
|
||||||
|
webhook_secret_hash TEXT NOT NULL DEFAULT '',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
-- App environment variables
|
-- App environment variables
|
||||||
CREATE TABLE app_env_vars (
|
CREATE TABLE IF NOT EXISTS app_env_vars (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
key TEXT NOT NULL,
|
key TEXT NOT NULL,
|
||||||
@@ -38,7 +40,7 @@ CREATE TABLE app_env_vars (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- App labels
|
-- App labels
|
||||||
CREATE TABLE app_labels (
|
CREATE TABLE IF NOT EXISTS app_labels (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
key TEXT NOT NULL,
|
key TEXT NOT NULL,
|
||||||
@@ -47,7 +49,7 @@ CREATE TABLE app_labels (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- App volume mounts
|
-- App volume mounts
|
||||||
CREATE TABLE app_volumes (
|
CREATE TABLE IF NOT EXISTS app_volumes (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
host_path TEXT NOT NULL,
|
host_path TEXT NOT NULL,
|
||||||
@@ -55,13 +57,24 @@ CREATE TABLE app_volumes (
|
|||||||
readonly INTEGER DEFAULT 0
|
readonly INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- App port mappings
|
||||||
|
CREATE TABLE IF NOT EXISTS app_ports (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
host_port INTEGER NOT NULL,
|
||||||
|
container_port INTEGER NOT NULL,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK(protocol IN ('tcp', 'udp')),
|
||||||
|
UNIQUE(host_port, protocol)
|
||||||
|
);
|
||||||
|
|
||||||
-- Webhook events log
|
-- Webhook events log
|
||||||
CREATE TABLE webhook_events (
|
CREATE TABLE IF NOT EXISTS webhook_events (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
event_type TEXT NOT NULL,
|
event_type TEXT NOT NULL,
|
||||||
branch TEXT NOT NULL,
|
branch TEXT NOT NULL,
|
||||||
commit_sha TEXT,
|
commit_sha TEXT,
|
||||||
|
commit_url TEXT,
|
||||||
payload TEXT,
|
payload TEXT,
|
||||||
matched INTEGER NOT NULL,
|
matched INTEGER NOT NULL,
|
||||||
processed INTEGER DEFAULT 0,
|
processed INTEGER DEFAULT 0,
|
||||||
@@ -69,13 +82,13 @@ CREATE TABLE webhook_events (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Deployments log
|
-- Deployments log
|
||||||
CREATE TABLE deployments (
|
CREATE TABLE IF NOT EXISTS deployments (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
webhook_event_id INTEGER REFERENCES webhook_events(id),
|
webhook_event_id INTEGER REFERENCES webhook_events(id),
|
||||||
commit_sha TEXT,
|
commit_sha TEXT,
|
||||||
|
commit_url TEXT,
|
||||||
image_id TEXT,
|
image_id TEXT,
|
||||||
container_id TEXT,
|
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
logs TEXT,
|
logs TEXT,
|
||||||
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -83,12 +96,14 @@ CREATE TABLE deployments (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
CREATE INDEX idx_apps_status ON apps(status);
|
CREATE INDEX IF NOT EXISTS idx_apps_status ON apps(status);
|
||||||
CREATE INDEX idx_apps_webhook_secret ON apps(webhook_secret);
|
CREATE INDEX IF NOT EXISTS idx_apps_webhook_secret ON apps(webhook_secret);
|
||||||
CREATE INDEX idx_app_env_vars_app_id ON app_env_vars(app_id);
|
CREATE INDEX IF NOT EXISTS idx_apps_webhook_secret_hash ON apps(webhook_secret_hash);
|
||||||
CREATE INDEX idx_app_labels_app_id ON app_labels(app_id);
|
CREATE INDEX IF NOT EXISTS idx_app_env_vars_app_id ON app_env_vars(app_id);
|
||||||
CREATE INDEX idx_app_volumes_app_id ON app_volumes(app_id);
|
CREATE INDEX IF NOT EXISTS idx_app_labels_app_id ON app_labels(app_id);
|
||||||
CREATE INDEX idx_webhook_events_app_id ON webhook_events(app_id);
|
CREATE INDEX IF NOT EXISTS idx_app_volumes_app_id ON app_volumes(app_id);
|
||||||
CREATE INDEX idx_webhook_events_created_at ON webhook_events(created_at);
|
CREATE INDEX IF NOT EXISTS idx_app_ports_app_id ON app_ports(app_id);
|
||||||
CREATE INDEX idx_deployments_app_id ON deployments(app_id);
|
CREATE INDEX IF NOT EXISTS idx_webhook_events_app_id ON webhook_events(app_id);
|
||||||
CREATE INDEX idx_deployments_started_at ON deployments(started_at);
|
CREATE INDEX IF NOT EXISTS idx_webhook_events_created_at ON webhook_events(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deployments_app_id ON deployments(app_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deployments_started_at ON deployments(started_at);
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
-- Remove container_id from apps table
|
|
||||||
-- Container is now looked up via Docker label (upaas.id) instead of stored in database
|
|
||||||
|
|
||||||
-- SQLite doesn't support DROP COLUMN before version 3.35.0 (2021-03-12)
|
|
||||||
-- Use table rebuild for broader compatibility
|
|
||||||
|
|
||||||
-- Create new table without container_id
|
|
||||||
CREATE TABLE apps_new (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT UNIQUE NOT NULL,
|
|
||||||
repo_url TEXT NOT NULL,
|
|
||||||
branch TEXT NOT NULL DEFAULT 'main',
|
|
||||||
dockerfile_path TEXT DEFAULT 'Dockerfile',
|
|
||||||
webhook_secret TEXT NOT NULL,
|
|
||||||
ssh_private_key TEXT NOT NULL,
|
|
||||||
ssh_public_key TEXT NOT NULL,
|
|
||||||
image_id TEXT,
|
|
||||||
status TEXT DEFAULT 'pending',
|
|
||||||
docker_network TEXT,
|
|
||||||
ntfy_topic TEXT,
|
|
||||||
slack_webhook TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Copy data (excluding container_id)
|
|
||||||
INSERT INTO apps_new (
|
|
||||||
id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
|
||||||
ssh_private_key, ssh_public_key, image_id, status,
|
|
||||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
|
||||||
ssh_private_key, ssh_public_key, image_id, status,
|
|
||||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
|
||||||
FROM apps;
|
|
||||||
|
|
||||||
-- Drop old table and rename new one
|
|
||||||
DROP TABLE apps;
|
|
||||||
ALTER TABLE apps_new RENAME TO apps;
|
|
||||||
|
|
||||||
-- Recreate indexes
|
|
||||||
CREATE INDEX idx_apps_status ON apps(status);
|
|
||||||
CREATE INDEX idx_apps_webhook_secret ON apps(webhook_secret);
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
-- Add port mappings for apps
|
|
||||||
|
|
||||||
CREATE TABLE app_ports (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
|
||||||
host_port INTEGER NOT NULL,
|
|
||||||
container_port INTEGER NOT NULL,
|
|
||||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK(protocol IN ('tcp', 'udp')),
|
|
||||||
UNIQUE(host_port, protocol)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_app_ports_app_id ON app_ports(app_id);
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- Add commit_url column to webhook_events and deployments tables
|
|
||||||
ALTER TABLE webhook_events ADD COLUMN commit_url TEXT;
|
|
||||||
ALTER TABLE deployments ADD COLUMN commit_url TEXT;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- Add webhook_secret_hash column for constant-time secret lookup
|
|
||||||
ALTER TABLE apps ADD COLUMN webhook_secret_hash TEXT NOT NULL DEFAULT '';
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- Add previous_image_id to apps for deployment rollback support
|
|
||||||
ALTER TABLE apps ADD COLUMN previous_image_id TEXT;
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -14,10 +14,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
dockertypes "github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/api/types/image"
|
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
@@ -25,9 +24,8 @@ import (
|
|||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// sshKeyPermissions is the file permission for SSH private keys.
|
// sshKeyPermissions is the file permission for SSH private keys.
|
||||||
@@ -41,8 +39,7 @@ const stopTimeoutSeconds = 10
|
|||||||
|
|
||||||
// gitImage is the Docker image used for git operations.
|
// gitImage is the Docker image used for git operations.
|
||||||
// alpine/git v2.47.2 - pulled 2025-12-30
|
// alpine/git v2.47.2 - pulled 2025-12-30
|
||||||
const gitImage = "alpine/git@sha256:" +
|
const gitImage = "alpine/git@sha256:d86f367afb53d022acc4377741e7334bc20add161bb10234272b91b459b4b7d8"
|
||||||
"d86f367afb53d022acc4377741e7334bc20add161bb10234272b91b459b4b7d8"
|
|
||||||
|
|
||||||
// ErrNotConnected is returned when Docker client is not connected.
|
// ErrNotConnected is returned when Docker client is not connected.
|
||||||
var ErrNotConnected = errors.New("docker client not connected")
|
var ErrNotConnected = errors.New("docker client not connected")
|
||||||
@@ -118,7 +115,7 @@ type BuildImageOptions struct {
|
|||||||
func (c *Client) BuildImage(
|
func (c *Client) BuildImage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
opts BuildImageOptions,
|
opts BuildImageOptions,
|
||||||
) (ImageID, error) {
|
) (string, error) {
|
||||||
if c.docker == nil {
|
if c.docker == nil {
|
||||||
return "", ErrNotConnected
|
return "", ErrNotConnected
|
||||||
}
|
}
|
||||||
@@ -146,8 +143,6 @@ type CreateContainerOptions struct {
|
|||||||
Volumes []VolumeMount
|
Volumes []VolumeMount
|
||||||
Ports []PortMapping
|
Ports []PortMapping
|
||||||
Network string
|
Network string
|
||||||
CPULimit float64 // CPU cores (0.5 = half a core). 0 means unlimited.
|
|
||||||
MemoryLimit int64 // Memory in bytes. 0 means unlimited.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// VolumeMount represents a volume mount.
|
// VolumeMount represents a volume mount.
|
||||||
@@ -164,14 +159,6 @@ type PortMapping struct {
|
|||||||
Protocol string // "tcp" or "udp"
|
Protocol string // "tcp" or "udp"
|
||||||
}
|
}
|
||||||
|
|
||||||
// nanoCPUsPerCPU is the number of NanoCPUs per CPU core.
|
|
||||||
const nanoCPUsPerCPU = 1e9
|
|
||||||
|
|
||||||
// cpuLimitToNanoCPUs converts a CPU limit (e.g. 0.5 cores) to Docker NanoCPUs.
|
|
||||||
func cpuLimitToNanoCPUs(cpuLimit float64) int64 {
|
|
||||||
return int64(cpuLimit * nanoCPUsPerCPU)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildPortConfig converts port mappings to Docker port configuration.
|
// buildPortConfig converts port mappings to Docker port configuration.
|
||||||
func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
|
func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
|
||||||
exposedPorts := make(nat.PortSet)
|
exposedPorts := make(nat.PortSet)
|
||||||
@@ -196,22 +183,28 @@ func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
|
|||||||
return exposedPorts, portBindings
|
return exposedPorts, portBindings
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildEnvSlice converts an env map to a Docker-compatible env slice.
|
// CreateContainer creates a new container.
|
||||||
func buildEnvSlice(env map[string]string) []string {
|
func (c *Client) CreateContainer(
|
||||||
envSlice := make([]string, 0, len(env))
|
ctx context.Context,
|
||||||
|
opts CreateContainerOptions,
|
||||||
|
) (string, error) {
|
||||||
|
if c.docker == nil {
|
||||||
|
return "", ErrNotConnected
|
||||||
|
}
|
||||||
|
|
||||||
for key, val := range env {
|
c.log.Info("creating container", "name", opts.Name, "image", opts.Image)
|
||||||
|
|
||||||
|
// Convert env map to slice
|
||||||
|
envSlice := make([]string, 0, len(opts.Env))
|
||||||
|
|
||||||
|
for key, val := range opts.Env {
|
||||||
envSlice = append(envSlice, key+"="+val)
|
envSlice = append(envSlice, key+"="+val)
|
||||||
}
|
}
|
||||||
|
|
||||||
return envSlice
|
// Convert volumes to mounts
|
||||||
}
|
mounts := make([]mount.Mount, 0, len(opts.Volumes))
|
||||||
|
|
||||||
// buildMounts converts volume mounts to Docker mount configuration.
|
for _, vol := range opts.Volumes {
|
||||||
func buildMounts(volumes []VolumeMount) []mount.Mount {
|
|
||||||
mounts := make([]mount.Mount, 0, len(volumes))
|
|
||||||
|
|
||||||
for _, vol := range volumes {
|
|
||||||
mounts = append(mounts, mount.Mount{
|
mounts = append(mounts, mount.Mount{
|
||||||
Type: mount.TypeBind,
|
Type: mount.TypeBind,
|
||||||
Source: vol.HostPath,
|
Source: vol.HostPath,
|
||||||
@@ -220,49 +213,21 @@ func buildMounts(volumes []VolumeMount) []mount.Mount {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return mounts
|
// Convert ports to exposed ports and port bindings
|
||||||
}
|
|
||||||
|
|
||||||
// buildResources builds Docker resource constraints from container options.
|
|
||||||
func buildResources(opts CreateContainerOptions) container.Resources {
|
|
||||||
resources := container.Resources{}
|
|
||||||
|
|
||||||
if opts.CPULimit > 0 {
|
|
||||||
resources.NanoCPUs = cpuLimitToNanoCPUs(opts.CPULimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.MemoryLimit > 0 {
|
|
||||||
resources.Memory = opts.MemoryLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
return resources
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateContainer creates a new container.
|
|
||||||
func (c *Client) CreateContainer(
|
|
||||||
ctx context.Context,
|
|
||||||
opts CreateContainerOptions,
|
|
||||||
) (ContainerID, error) {
|
|
||||||
if c.docker == nil {
|
|
||||||
return "", ErrNotConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
c.log.Info("creating container", "name", opts.Name, "image", opts.Image)
|
|
||||||
|
|
||||||
exposedPorts, portBindings := buildPortConfig(opts.Ports)
|
exposedPorts, portBindings := buildPortConfig(opts.Ports)
|
||||||
|
|
||||||
|
// Create container
|
||||||
resp, err := c.docker.ContainerCreate(ctx,
|
resp, err := c.docker.ContainerCreate(ctx,
|
||||||
&container.Config{
|
&container.Config{
|
||||||
Image: opts.Image,
|
Image: opts.Image,
|
||||||
Env: buildEnvSlice(opts.Env),
|
Env: envSlice,
|
||||||
Labels: opts.Labels,
|
Labels: opts.Labels,
|
||||||
ExposedPorts: exposedPorts,
|
ExposedPorts: exposedPorts,
|
||||||
},
|
},
|
||||||
&container.HostConfig{
|
&container.HostConfig{
|
||||||
Mounts: buildMounts(opts.Volumes),
|
Mounts: mounts,
|
||||||
PortBindings: portBindings,
|
PortBindings: portBindings,
|
||||||
NetworkMode: container.NetworkMode(opts.Network),
|
NetworkMode: container.NetworkMode(opts.Network),
|
||||||
Resources: buildResources(opts),
|
|
||||||
RestartPolicy: container.RestartPolicy{
|
RestartPolicy: container.RestartPolicy{
|
||||||
Name: container.RestartPolicyUnlessStopped,
|
Name: container.RestartPolicyUnlessStopped,
|
||||||
},
|
},
|
||||||
@@ -275,18 +240,18 @@ func (c *Client) CreateContainer(
|
|||||||
return "", fmt.Errorf("failed to create container: %w", err)
|
return "", fmt.Errorf("failed to create container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ContainerID(resp.ID), nil
|
return resp.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartContainer starts a container.
|
// StartContainer starts a container.
|
||||||
func (c *Client) StartContainer(ctx context.Context, containerID ContainerID) error {
|
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
|
||||||
if c.docker == nil {
|
if c.docker == nil {
|
||||||
return ErrNotConnected
|
return ErrNotConnected
|
||||||
}
|
}
|
||||||
|
|
||||||
c.log.Info("starting container", "id", containerID)
|
c.log.Info("starting container", "id", containerID)
|
||||||
|
|
||||||
err := c.docker.ContainerStart(ctx, containerID.String(), container.StartOptions{})
|
err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to start container: %w", err)
|
return fmt.Errorf("failed to start container: %w", err)
|
||||||
}
|
}
|
||||||
@@ -295,7 +260,7 @@ func (c *Client) StartContainer(ctx context.Context, containerID ContainerID) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StopContainer stops a container.
|
// StopContainer stops a container.
|
||||||
func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) error {
|
func (c *Client) StopContainer(ctx context.Context, containerID string) error {
|
||||||
if c.docker == nil {
|
if c.docker == nil {
|
||||||
return ErrNotConnected
|
return ErrNotConnected
|
||||||
}
|
}
|
||||||
@@ -304,11 +269,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) err
|
|||||||
|
|
||||||
timeout := stopTimeoutSeconds
|
timeout := stopTimeoutSeconds
|
||||||
|
|
||||||
err := c.docker.ContainerStop(
|
err := c.docker.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
|
||||||
ctx,
|
|
||||||
containerID.String(),
|
|
||||||
container.StopOptions{Timeout: &timeout},
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to stop container: %w", err)
|
return fmt.Errorf("failed to stop container: %w", err)
|
||||||
}
|
}
|
||||||
@@ -319,7 +280,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) err
|
|||||||
// RemoveContainer removes a container.
|
// RemoveContainer removes a container.
|
||||||
func (c *Client) RemoveContainer(
|
func (c *Client) RemoveContainer(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
containerID ContainerID,
|
containerID string,
|
||||||
force bool,
|
force bool,
|
||||||
) error {
|
) error {
|
||||||
if c.docker == nil {
|
if c.docker == nil {
|
||||||
@@ -328,11 +289,7 @@ func (c *Client) RemoveContainer(
|
|||||||
|
|
||||||
c.log.Info("removing container", "id", containerID, "force", force)
|
c.log.Info("removing container", "id", containerID, "force", force)
|
||||||
|
|
||||||
err := c.docker.ContainerRemove(
|
err := c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
|
||||||
ctx,
|
|
||||||
containerID.String(),
|
|
||||||
container.RemoveOptions{Force: force},
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove container: %w", err)
|
return fmt.Errorf("failed to remove container: %w", err)
|
||||||
}
|
}
|
||||||
@@ -343,7 +300,7 @@ func (c *Client) RemoveContainer(
|
|||||||
// ContainerLogs returns the logs for a container.
|
// ContainerLogs returns the logs for a container.
|
||||||
func (c *Client) ContainerLogs(
|
func (c *Client) ContainerLogs(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
containerID ContainerID,
|
containerID string,
|
||||||
tail string,
|
tail string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
if c.docker == nil {
|
if c.docker == nil {
|
||||||
@@ -356,7 +313,7 @@ func (c *Client) ContainerLogs(
|
|||||||
Tail: tail,
|
Tail: tail,
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, err := c.docker.ContainerLogs(ctx, containerID.String(), opts)
|
reader, err := c.docker.ContainerLogs(ctx, containerID, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get container logs: %w", err)
|
return "", fmt.Errorf("failed to get container logs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -379,13 +336,13 @@ func (c *Client) ContainerLogs(
|
|||||||
// IsContainerRunning checks if a container is running.
|
// IsContainerRunning checks if a container is running.
|
||||||
func (c *Client) IsContainerRunning(
|
func (c *Client) IsContainerRunning(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
containerID ContainerID,
|
containerID string,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
if c.docker == nil {
|
if c.docker == nil {
|
||||||
return false, ErrNotConnected
|
return false, ErrNotConnected
|
||||||
}
|
}
|
||||||
|
|
||||||
inspect, err := c.docker.ContainerInspect(ctx, containerID.String())
|
inspect, err := c.docker.ContainerInspect(ctx, containerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to inspect container: %w", err)
|
return false, fmt.Errorf("failed to inspect container: %w", err)
|
||||||
}
|
}
|
||||||
@@ -396,13 +353,13 @@ func (c *Client) IsContainerRunning(
|
|||||||
// IsContainerHealthy checks if a container is healthy.
|
// IsContainerHealthy checks if a container is healthy.
|
||||||
func (c *Client) IsContainerHealthy(
|
func (c *Client) IsContainerHealthy(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
containerID ContainerID,
|
containerID string,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
if c.docker == nil {
|
if c.docker == nil {
|
||||||
return false, ErrNotConnected
|
return false, ErrNotConnected
|
||||||
}
|
}
|
||||||
|
|
||||||
inspect, err := c.docker.ContainerInspect(ctx, containerID.String())
|
inspect, err := c.docker.ContainerInspect(ctx, containerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to inspect container: %w", err)
|
return false, fmt.Errorf("failed to inspect container: %w", err)
|
||||||
}
|
}
|
||||||
@@ -420,7 +377,7 @@ const LabelUpaasID = "upaas.id"
|
|||||||
|
|
||||||
// ContainerInfo contains basic information about a container.
|
// ContainerInfo contains basic information about a container.
|
||||||
type ContainerInfo struct {
|
type ContainerInfo struct {
|
||||||
ID ContainerID
|
ID string
|
||||||
Running bool
|
Running bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +412,7 @@ func (c *Client) FindContainerByAppID(
|
|||||||
ctr := containers[0]
|
ctr := containers[0]
|
||||||
|
|
||||||
return &ContainerInfo{
|
return &ContainerInfo{
|
||||||
ID: ContainerID(ctr.ID),
|
ID: ctr.ID,
|
||||||
Running: ctr.State == "running",
|
Running: ctr.State == "running",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -478,8 +435,7 @@ type CloneResult struct {
|
|||||||
CommitSHA string // The HEAD commit SHA after clone/checkout
|
CommitSHA string // The HEAD commit SHA after clone/checkout
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloneRepo clones a git repository using SSH and optionally checks out a
|
// CloneRepo clones a git repository using SSH and optionally checks out a specific commit.
|
||||||
// specific commit.
|
|
||||||
// containerDir is the path inside the upaas container (for writing files).
|
// containerDir is the path inside the upaas container (for writing files).
|
||||||
// hostDir is the corresponding path on the Docker host (for bind mounts).
|
// hostDir is the corresponding path on the Docker host (for bind mounts).
|
||||||
// If commitSHA is provided, that specific commit will be checked out.
|
// If commitSHA is provided, that specific commit will be checked out.
|
||||||
@@ -523,24 +479,10 @@ func (c *Client) CloneRepo(
|
|||||||
return c.performClone(ctx, cfg)
|
return c.performClone(ctx, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveImage removes a Docker image by ID or tag.
|
|
||||||
// It returns nil if the image was successfully removed or does not exist.
|
|
||||||
func (c *Client) RemoveImage(ctx context.Context, imageID ImageID) error {
|
|
||||||
_, err := c.docker.ImageRemove(ctx, imageID.String(), image.RemoveOptions{
|
|
||||||
Force: true,
|
|
||||||
PruneChildren: true,
|
|
||||||
})
|
|
||||||
if err != nil && !client.IsErrNotFound(err) {
|
|
||||||
return fmt.Errorf("failed to remove image %s: %w", imageID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) performBuild(
|
func (c *Client) performBuild(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
opts BuildImageOptions,
|
opts BuildImageOptions,
|
||||||
) (ImageID, error) {
|
) (string, error) {
|
||||||
// Create tar archive of build context
|
// Create tar archive of build context
|
||||||
tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{})
|
tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -555,7 +497,7 @@ func (c *Client) performBuild(
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Build image
|
// Build image
|
||||||
resp, err := c.docker.ImageBuild(ctx, tarArchive, dockertypes.ImageBuildOptions{
|
resp, err := c.docker.ImageBuild(ctx, tarArchive, types.ImageBuildOptions{
|
||||||
Dockerfile: opts.DockerfilePath,
|
Dockerfile: opts.DockerfilePath,
|
||||||
Tags: opts.Tags,
|
Tags: opts.Tags,
|
||||||
Remove: true,
|
Remove: true,
|
||||||
@@ -585,7 +527,7 @@ func (c *Client) performBuild(
|
|||||||
return "", fmt.Errorf("failed to inspect image: %w", inspectErr)
|
return "", fmt.Errorf("failed to inspect image: %w", inspectErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ImageID(inspect.ID), nil
|
return inspect.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", nil
|
return "", nil
|
||||||
@@ -594,13 +536,11 @@ func (c *Client) performBuild(
|
|||||||
// scannerInitialBufferSize is the initial buffer size for the build log scanner.
|
// scannerInitialBufferSize is the initial buffer size for the build log scanner.
|
||||||
const scannerInitialBufferSize = 64 * 1024 // 64KB
|
const scannerInitialBufferSize = 64 * 1024 // 64KB
|
||||||
|
|
||||||
// scannerMaxBufferSize is the max buffer size for build log lines
|
// scannerMaxBufferSize is the max buffer size for build log lines (base64 layers can be large).
|
||||||
// (base64 layers can be large).
|
|
||||||
const scannerMaxBufferSize = 1024 * 1024 // 1MB
|
const scannerMaxBufferSize = 1024 * 1024 // 1MB
|
||||||
|
|
||||||
// streamBuildOutput reads Docker build output line by line and writes to
|
// streamBuildOutput reads Docker build output line by line and writes to stdout and optional log writer.
|
||||||
// stdout and optional log writer. Docker sends newline-delimited JSON, so
|
// Docker sends newline-delimited JSON, so reading line by line ensures each log entry is written immediately.
|
||||||
// reading line by line ensures each log entry is written immediately.
|
|
||||||
func (c *Client) streamBuildOutput(body io.Reader, logWriter io.Writer) error {
|
func (c *Client) streamBuildOutput(body io.Reader, logWriter io.Writer) error {
|
||||||
scanner := bufio.NewScanner(body)
|
scanner := bufio.NewScanner(body)
|
||||||
buf := make([]byte, 0, scannerInitialBufferSize)
|
buf := make([]byte, 0, scannerInitialBufferSize)
|
||||||
@@ -628,10 +568,7 @@ func (c *Client) streamBuildOutput(body io.Reader, logWriter io.Writer) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) performClone(
|
func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResult, error) {
|
||||||
ctx context.Context,
|
|
||||||
cfg *cloneConfig,
|
|
||||||
) (*CloneResult, error) {
|
|
||||||
// Create work directory for clone destination
|
// Create work directory for clone destination
|
||||||
err := os.MkdirAll(cfg.containerDir, workDirPermissions)
|
err := os.MkdirAll(cfg.containerDir, workDirPermissions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -651,26 +588,22 @@ func (c *Client) performClone(
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
gitContainerID, err := c.createGitContainer(ctx, cfg)
|
containerID, err := c.createGitContainer(ctx, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = c.docker.ContainerRemove(
|
_ = c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true})
|
||||||
ctx,
|
|
||||||
gitContainerID.String(),
|
|
||||||
container.RemoveOptions{Force: true},
|
|
||||||
)
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return c.runGitClone(ctx, gitContainerID)
|
return c.runGitClone(ctx, containerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) createGitContainer(
|
func (c *Client) createGitContainer(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
cfg *cloneConfig,
|
cfg *cloneConfig,
|
||||||
) (ContainerID, error) {
|
) (string, error) {
|
||||||
gitSSHCmd := "ssh -i /keys/deploy_key -o StrictHostKeyChecking=no"
|
gitSSHCmd := "ssh -i /keys/deploy_key -o StrictHostKeyChecking=no"
|
||||||
|
|
||||||
// Build the git command using environment variables to avoid shell injection.
|
// Build the git command using environment variables to avoid shell injection.
|
||||||
@@ -699,8 +632,7 @@ func (c *Client) createGitContainer(
|
|||||||
entrypoint := []string{}
|
entrypoint := []string{}
|
||||||
cmd := []string{"sh", "-c", script}
|
cmd := []string{"sh", "-c", script}
|
||||||
|
|
||||||
// Use host paths for Docker bind mounts
|
// Use host paths for Docker bind mounts (Docker runs on the host, not in our container)
|
||||||
// (Docker runs on the host, not in our container)
|
|
||||||
resp, err := c.docker.ContainerCreate(ctx,
|
resp, err := c.docker.ContainerCreate(ctx,
|
||||||
&container.Config{
|
&container.Config{
|
||||||
Image: gitImage,
|
Image: gitImage,
|
||||||
@@ -728,23 +660,16 @@ func (c *Client) createGitContainer(
|
|||||||
return "", fmt.Errorf("failed to create git container: %w", err)
|
return "", fmt.Errorf("failed to create git container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ContainerID(resp.ID), nil
|
return resp.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) runGitClone(
|
func (c *Client) runGitClone(ctx context.Context, containerID string) (*CloneResult, error) {
|
||||||
ctx context.Context,
|
err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
|
||||||
containerID ContainerID,
|
|
||||||
) (*CloneResult, error) {
|
|
||||||
err := c.docker.ContainerStart(ctx, containerID.String(), container.StartOptions{})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to start git container: %w", err)
|
return nil, fmt.Errorf("failed to start git container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
statusCh, errCh := c.docker.ContainerWait(
|
statusCh, errCh := c.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
|
||||||
ctx,
|
|
||||||
containerID.String(),
|
|
||||||
container.WaitConditionNotRunning,
|
|
||||||
)
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case err := <-errCh:
|
case err := <-errCh:
|
||||||
|
|||||||
@@ -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) }
|
|
||||||
@@ -6,14 +6,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mainBranch is the branch name used across validation tests.
|
|
||||||
const mainBranch = "main"
|
|
||||||
|
|
||||||
func TestValidBranchRegex(t *testing.T) {
|
func TestValidBranchRegex(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
valid := []string{
|
valid := []string{
|
||||||
mainBranch,
|
"main",
|
||||||
"develop",
|
"develop",
|
||||||
"feature/my-feature",
|
"feature/my-feature",
|
||||||
"release-1.0",
|
"release-1.0",
|
||||||
@@ -73,7 +70,7 @@ func TestValidCommitSHARegex(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloneRepoRejectsInjection(t *testing.T) {
|
func TestCloneRepoRejectsInjection(t *testing.T) { //nolint:funlen // table-driven test
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
c := &Client{
|
c := &Client{
|
||||||
@@ -103,25 +100,25 @@ func TestCloneRepoRejectsInjection(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "injection in commitSHA",
|
name: "injection in commitSHA",
|
||||||
branch: mainBranch,
|
branch: "main",
|
||||||
commitSHA: "not-a-sha; rm -rf /",
|
commitSHA: "not-a-sha; rm -rf /",
|
||||||
wantErr: ErrInvalidCommitSHA,
|
wantErr: ErrInvalidCommitSHA,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "short SHA rejected",
|
name: "short SHA rejected",
|
||||||
branch: mainBranch,
|
branch: "main",
|
||||||
commitSHA: "abc123",
|
commitSHA: "abc123",
|
||||||
wantErr: ErrInvalidCommitSHA,
|
wantErr: ErrInvalidCommitSHA,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid inputs pass validation (hit NotConnected)",
|
name: "valid inputs pass validation (hit NotConnected)",
|
||||||
branch: mainBranch,
|
branch: "main",
|
||||||
commitSHA: "abc123def456789012345678901234567890abcd",
|
commitSHA: "abc123def456789012345678901234567890abcd",
|
||||||
wantErr: ErrNotConnected,
|
wantErr: ErrNotConnected,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid branch no SHA passes validation (hit NotConnected)",
|
name: "valid branch no SHA passes validation (hit NotConnected)",
|
||||||
branch: mainBranch,
|
branch: "main",
|
||||||
wantErr: ErrNotConnected,
|
wantErr: ErrNotConnected,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/models"
|
"git.eeqj.de/sneak/upaas/internal/models"
|
||||||
|
"git.eeqj.de/sneak/upaas/internal/service/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
// apiAppResponse is the JSON representation of an app.
|
// apiAppResponse is the JSON representation of an app.
|
||||||
@@ -73,38 +74,40 @@ func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
|
|||||||
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
|
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
|
||||||
// and sets a session cookie.
|
// and sets a session cookie.
|
||||||
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
||||||
|
type loginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
type loginResponse struct {
|
type loginResponse struct {
|
||||||
UserID int64 `json:"userId"`
|
UserID int64 `json:"userId"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
var req map[string]string
|
var req loginRequest
|
||||||
|
|
||||||
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
||||||
if decodeErr != nil {
|
if decodeErr != nil {
|
||||||
h.respondJSON(writer, request,
|
h.respondJSON(writer, request,
|
||||||
map[string]string{jsonKeyError: "invalid JSON body"},
|
map[string]string{"error": "invalid JSON body"},
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
username := req["username"]
|
if req.Username == "" || req.Password == "" {
|
||||||
credential := req["password"]
|
|
||||||
|
|
||||||
if username == "" || credential == "" {
|
|
||||||
h.respondJSON(writer, request,
|
h.respondJSON(writer, request,
|
||||||
map[string]string{jsonKeyError: "username and password are required"},
|
map[string]string{"error": "username and password are required"},
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, authErr := h.auth.Authenticate(request.Context(), username, credential)
|
user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password)
|
||||||
if authErr != nil {
|
if authErr != nil {
|
||||||
h.respondJSON(writer, request,
|
h.respondJSON(writer, request,
|
||||||
map[string]string{jsonKeyError: "invalid credentials"},
|
map[string]string{"error": "invalid credentials"},
|
||||||
http.StatusUnauthorized)
|
http.StatusUnauthorized)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -114,7 +117,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
|||||||
if sessionErr != nil {
|
if sessionErr != nil {
|
||||||
h.log.Error("api: failed to create session", "error", sessionErr)
|
h.log.Error("api: failed to create session", "error", sessionErr)
|
||||||
h.respondJSON(writer, request,
|
h.respondJSON(writer, request,
|
||||||
map[string]string{jsonKeyError: "failed to create session"},
|
map[string]string{"error": "failed to create session"},
|
||||||
http.StatusInternalServerError)
|
http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -133,7 +136,7 @@ func (h *Handlers) HandleAPIListApps() http.HandlerFunc {
|
|||||||
apps, err := h.appService.ListApps(request.Context())
|
apps, err := h.appService.ListApps(request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.respondJSON(writer, request,
|
h.respondJSON(writer, request,
|
||||||
map[string]string{jsonKeyError: "failed to list apps"},
|
map[string]string{"error": "failed to list apps"},
|
||||||
http.StatusInternalServerError)
|
http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -156,7 +159,7 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
|
|||||||
application, err := h.appService.GetApp(request.Context(), appID)
|
application, err := h.appService.GetApp(request.Context(), appID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.respondJSON(writer, request,
|
h.respondJSON(writer, request,
|
||||||
map[string]string{jsonKeyError: "internal server error"},
|
map[string]string{"error": "internal server error"},
|
||||||
http.StatusInternalServerError)
|
http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -164,7 +167,7 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
|
|||||||
|
|
||||||
if application == nil {
|
if application == nil {
|
||||||
h.respondJSON(writer, request,
|
h.respondJSON(writer, request,
|
||||||
map[string]string{jsonKeyError: "app not found"},
|
map[string]string{"error": "app not found"},
|
||||||
http.StatusNotFound)
|
http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -174,6 +177,106 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleAPICreateApp returns a handler that creates a new app.
|
||||||
|
func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
|
||||||
|
type createRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
RepoURL string `json:"repoUrl"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
DockerfilePath string `json:"dockerfilePath"`
|
||||||
|
DockerNetwork string `json:"dockerNetwork"`
|
||||||
|
NtfyTopic string `json:"ntfyTopic"`
|
||||||
|
SlackWebhook string `json:"slackWebhook"`
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
var req createRequest
|
||||||
|
|
||||||
|
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
||||||
|
if decodeErr != nil {
|
||||||
|
h.respondJSON(writer, request,
|
||||||
|
map[string]string{"error": "invalid JSON body"},
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" || req.RepoURL == "" {
|
||||||
|
h.respondJSON(writer, request,
|
||||||
|
map[string]string{"error": "name and repo_url are required"},
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nameErr := validateAppName(req.Name)
|
||||||
|
if nameErr != nil {
|
||||||
|
h.respondJSON(writer, request,
|
||||||
|
map[string]string{"error": "invalid app name: " + nameErr.Error()},
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{
|
||||||
|
Name: req.Name,
|
||||||
|
RepoURL: req.RepoURL,
|
||||||
|
Branch: req.Branch,
|
||||||
|
DockerfilePath: req.DockerfilePath,
|
||||||
|
DockerNetwork: req.DockerNetwork,
|
||||||
|
NtfyTopic: req.NtfyTopic,
|
||||||
|
SlackWebhook: req.SlackWebhook,
|
||||||
|
})
|
||||||
|
if createErr != nil {
|
||||||
|
h.log.Error("api: failed to create app", "error", createErr)
|
||||||
|
h.respondJSON(writer, request,
|
||||||
|
map[string]string{"error": "failed to create app"},
|
||||||
|
http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.respondJSON(writer, request, appToAPI(createdApp), http.StatusCreated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIDeleteApp returns a handler that deletes an app.
|
||||||
|
func (h *Handlers) HandleAPIDeleteApp() http.HandlerFunc {
|
||||||
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
appID := chi.URLParam(request, "id")
|
||||||
|
|
||||||
|
application, err := h.appService.GetApp(request.Context(), appID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondJSON(writer, request,
|
||||||
|
map[string]string{"error": "internal server error"},
|
||||||
|
http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if application == nil {
|
||||||
|
h.respondJSON(writer, request,
|
||||||
|
map[string]string{"error": "app not found"},
|
||||||
|
http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteErr := h.appService.DeleteApp(request.Context(), application)
|
||||||
|
if deleteErr != nil {
|
||||||
|
h.log.Error("api: failed to delete app", "error", deleteErr)
|
||||||
|
h.respondJSON(writer, request,
|
||||||
|
map[string]string{"error": "failed to delete app"},
|
||||||
|
http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "deleted"}, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// deploymentsPageLimit is the default number of deployments per page.
|
// deploymentsPageLimit is the default number of deployments per page.
|
||||||
const deploymentsPageLimit = 20
|
const deploymentsPageLimit = 20
|
||||||
|
|
||||||
@@ -185,7 +288,7 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
|
|||||||
application, err := h.appService.GetApp(request.Context(), appID)
|
application, err := h.appService.GetApp(request.Context(), appID)
|
||||||
if err != nil || application == nil {
|
if err != nil || application == nil {
|
||||||
h.respondJSON(writer, request,
|
h.respondJSON(writer, request,
|
||||||
map[string]string{jsonKeyError: "app not found"},
|
map[string]string{"error": "app not found"},
|
||||||
http.StatusNotFound)
|
http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -205,7 +308,7 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
if deployErr != nil {
|
if deployErr != nil {
|
||||||
h.respondJSON(writer, request,
|
h.respondJSON(writer, request,
|
||||||
map[string]string{jsonKeyError: "failed to list deployments"},
|
map[string]string{"error": "failed to list deployments"},
|
||||||
http.StatusInternalServerError)
|
http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -220,6 +323,35 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleAPITriggerDeploy returns a handler that triggers a deployment for an app.
|
||||||
|
func (h *Handlers) HandleAPITriggerDeploy() http.HandlerFunc {
|
||||||
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
appID := chi.URLParam(request, "id")
|
||||||
|
|
||||||
|
application, err := h.appService.GetApp(request.Context(), appID)
|
||||||
|
if err != nil || application == nil {
|
||||||
|
h.respondJSON(writer, request,
|
||||||
|
map[string]string{"error": "app not found"},
|
||||||
|
http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deployErr := h.deploy.Deploy(request.Context(), application, nil, true)
|
||||||
|
if deployErr != nil {
|
||||||
|
h.log.Error("api: failed to trigger deploy", "error", deployErr)
|
||||||
|
h.respondJSON(writer, request,
|
||||||
|
map[string]string{"error": deployErr.Error()},
|
||||||
|
http.StatusConflict)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "deploying"}, http.StatusAccepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HandleAPIWhoAmI returns a handler that shows the current authenticated user.
|
// HandleAPIWhoAmI returns a handler that shows the current authenticated user.
|
||||||
func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
|
func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
|
||||||
type whoAmIResponse struct {
|
type whoAmIResponse struct {
|
||||||
@@ -231,7 +363,7 @@ func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
|
|||||||
user, err := h.auth.GetCurrentUser(request.Context(), request)
|
user, err := h.auth.GetCurrentUser(request.Context(), request)
|
||||||
if err != nil || user == nil {
|
if err != nil || user == nil {
|
||||||
h.respondJSON(writer, request,
|
h.respondJSON(writer, request,
|
||||||
map[string]string{jsonKeyError: "unauthorized"},
|
map[string]string{"error": "unauthorized"},
|
||||||
http.StatusUnauthorized)
|
http.StatusUnauthorized)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/service/app"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// apiRouter builds a chi router with the API routes using session auth middleware.
|
// apiRouter builds a chi router with the API routes using session auth middleware.
|
||||||
@@ -25,7 +23,10 @@ func apiRouter(tc *testContext) http.Handler {
|
|||||||
apiR.Use(tc.middleware.APISessionAuth())
|
apiR.Use(tc.middleware.APISessionAuth())
|
||||||
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
|
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
|
||||||
apiR.Get("/apps", tc.handlers.HandleAPIListApps())
|
apiR.Get("/apps", tc.handlers.HandleAPIListApps())
|
||||||
|
apiR.Post("/apps", tc.handlers.HandleAPICreateApp())
|
||||||
apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp())
|
apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp())
|
||||||
|
apiR.Delete("/apps/{id}", tc.handlers.HandleAPIDeleteApp())
|
||||||
|
apiR.Post("/apps/{id}/deploy", tc.handlers.HandleAPITriggerDeploy())
|
||||||
apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments())
|
apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -47,12 +48,7 @@ func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
|
|||||||
r := apiRouter(tc)
|
r := apiRouter(tc)
|
||||||
|
|
||||||
loginBody := `{"username":"admin","password":"password123"}`
|
loginBody := `{"username":"admin","password":"password123"}`
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(loginBody))
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
|
||||||
"/api/v1/login",
|
|
||||||
strings.NewReader(loginBody),
|
|
||||||
)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
@@ -66,16 +62,23 @@ func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
|
|||||||
return tc, cookies
|
return tc, cookies
|
||||||
}
|
}
|
||||||
|
|
||||||
// apiGet makes an authenticated GET request using session cookies.
|
// apiRequest makes an authenticated API request using session cookies.
|
||||||
func apiGet(
|
func apiRequest(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
tc *testContext,
|
tc *testContext,
|
||||||
cookies []*http.Cookie,
|
cookies []*http.Cookie,
|
||||||
path string,
|
method, path string,
|
||||||
|
body string,
|
||||||
) *httptest.ResponseRecorder {
|
) *httptest.ResponseRecorder {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
|
var req *http.Request
|
||||||
|
if body != "" {
|
||||||
|
req = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
} else {
|
||||||
|
req = httptest.NewRequest(method, path, nil)
|
||||||
|
}
|
||||||
|
|
||||||
for _, c := range cookies {
|
for _, c := range cookies {
|
||||||
req.AddCookie(c)
|
req.AddCookie(c)
|
||||||
@@ -100,12 +103,7 @@ func TestAPILoginSuccess(t *testing.T) {
|
|||||||
r := apiRouter(tc)
|
r := apiRouter(tc)
|
||||||
|
|
||||||
body := `{"username":"admin","password":"password123"}`
|
body := `{"username":"admin","password":"password123"}`
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
|
||||||
"/api/v1/login",
|
|
||||||
strings.NewReader(body),
|
|
||||||
)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
@@ -132,12 +130,7 @@ func TestAPILoginInvalidCredentials(t *testing.T) {
|
|||||||
r := apiRouter(tc)
|
r := apiRouter(tc)
|
||||||
|
|
||||||
body := `{"username":"admin","password":"wrong"}`
|
body := `{"username":"admin","password":"wrong"}`
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
|
||||||
"/api/v1/login",
|
|
||||||
strings.NewReader(body),
|
|
||||||
)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
@@ -154,12 +147,7 @@ func TestAPILoginMissingFields(t *testing.T) {
|
|||||||
r := apiRouter(tc)
|
r := apiRouter(tc)
|
||||||
|
|
||||||
body := `{"username":"","password":""}`
|
body := `{"username":"","password":""}`
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
|
||||||
"/api/v1/login",
|
|
||||||
strings.NewReader(body),
|
|
||||||
)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
@@ -175,9 +163,7 @@ func TestAPIRejectsUnauthenticated(t *testing.T) {
|
|||||||
|
|
||||||
r := apiRouter(tc)
|
r := apiRouter(tc)
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
|
||||||
t.Context(), http.MethodGet, "/api/v1/apps", nil,
|
|
||||||
)
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rr, req)
|
r.ServeHTTP(rr, req)
|
||||||
|
|
||||||
@@ -189,7 +175,7 @@ func TestAPIWhoAmI(t *testing.T) {
|
|||||||
|
|
||||||
tc, cookies := setupAPITest(t)
|
tc, cookies := setupAPITest(t)
|
||||||
|
|
||||||
rr := apiGet(t, tc, cookies, "/api/v1/whoami")
|
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/whoami", "")
|
||||||
assert.Equal(t, http.StatusOK, rr.Code)
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
var resp map[string]any
|
var resp map[string]any
|
||||||
@@ -202,7 +188,7 @@ func TestAPIListAppsEmpty(t *testing.T) {
|
|||||||
|
|
||||||
tc, cookies := setupAPITest(t)
|
tc, cookies := setupAPITest(t)
|
||||||
|
|
||||||
rr := apiGet(t, tc, cookies, "/api/v1/apps")
|
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps", "")
|
||||||
assert.Equal(t, http.StatusOK, rr.Code)
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
var apps []any
|
var apps []any
|
||||||
@@ -210,23 +196,52 @@ func TestAPIListAppsEmpty(t *testing.T) {
|
|||||||
assert.Empty(t, apps)
|
assert.Empty(t, apps)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPICreateApp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tc, cookies := setupAPITest(t)
|
||||||
|
|
||||||
|
body := `{"name":"test-app","repoUrl":"https://github.com/example/repo"}`
|
||||||
|
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
|
||||||
|
assert.Equal(t, http.StatusCreated, rr.Code)
|
||||||
|
|
||||||
|
var app map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
|
||||||
|
assert.Equal(t, "test-app", app["name"])
|
||||||
|
assert.Equal(t, "pending", app["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPICreateAppValidation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tc, cookies := setupAPITest(t)
|
||||||
|
|
||||||
|
body := `{"name":"","repoUrl":""}`
|
||||||
|
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAPIGetApp(t *testing.T) {
|
func TestAPIGetApp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tc, cookies := setupAPITest(t)
|
tc, cookies := setupAPITest(t)
|
||||||
|
|
||||||
created, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{
|
body := `{"name":"my-app","repoUrl":"https://github.com/example/repo"}`
|
||||||
Name: "my-app",
|
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
|
||||||
RepoURL: "https://github.com/example/repo",
|
require.Equal(t, http.StatusCreated, rr.Code)
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
rr := apiGet(t, tc, cookies, "/api/v1/apps/"+created.ID)
|
var created map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
|
||||||
|
|
||||||
|
appID, ok := created["id"].(string)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
|
||||||
assert.Equal(t, http.StatusOK, rr.Code)
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
var resp map[string]any
|
var app map[string]any
|
||||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
|
||||||
assert.Equal(t, "my-app", resp["name"])
|
assert.Equal(t, "my-app", app["name"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIGetAppNotFound(t *testing.T) {
|
func TestAPIGetAppNotFound(t *testing.T) {
|
||||||
@@ -234,7 +249,29 @@ func TestAPIGetAppNotFound(t *testing.T) {
|
|||||||
|
|
||||||
tc, cookies := setupAPITest(t)
|
tc, cookies := setupAPITest(t)
|
||||||
|
|
||||||
rr := apiGet(t, tc, cookies, "/api/v1/apps/nonexistent")
|
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/nonexistent", "")
|
||||||
|
assert.Equal(t, http.StatusNotFound, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIDeleteApp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tc, cookies := setupAPITest(t)
|
||||||
|
|
||||||
|
body := `{"name":"delete-me","repoUrl":"https://github.com/example/repo"}`
|
||||||
|
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
|
||||||
|
require.Equal(t, http.StatusCreated, rr.Code)
|
||||||
|
|
||||||
|
var created map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
|
||||||
|
|
||||||
|
appID, ok := created["id"].(string)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
rr = apiRequest(t, tc, cookies, http.MethodDelete, "/api/v1/apps/"+appID, "")
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
|
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
|
||||||
assert.Equal(t, http.StatusNotFound, rr.Code)
|
assert.Equal(t, http.StatusNotFound, rr.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,13 +280,17 @@ func TestAPIListDeployments(t *testing.T) {
|
|||||||
|
|
||||||
tc, cookies := setupAPITest(t)
|
tc, cookies := setupAPITest(t)
|
||||||
|
|
||||||
created, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{
|
body := `{"name":"deploy-app","repoUrl":"https://github.com/example/repo"}`
|
||||||
Name: "deploy-app",
|
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
|
||||||
RepoURL: "https://github.com/example/repo",
|
require.Equal(t, http.StatusCreated, rr.Code)
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
rr := apiGet(t, tc, cookies, "/api/v1/apps/"+created.ID+"/deployments")
|
var created map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
|
||||||
|
|
||||||
|
appID, ok := created["id"].(string)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID+"/deployments", "")
|
||||||
assert.Equal(t, http.StatusOK, rr.Code)
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
var deployments []any
|
var deployments []any
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -16,10 +15,9 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/models"
|
||||||
"sneak.berlin/go/upaas/internal/models"
|
"git.eeqj.de/sneak/upaas/internal/service/app"
|
||||||
"sneak.berlin/go/upaas/internal/service/app"
|
"git.eeqj.de/sneak/upaas/templates"
|
||||||
"sneak.berlin/go/upaas/templates"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -29,23 +27,6 @@ const (
|
|||||||
deploymentsHistoryLimit = 50
|
deploymentsHistoryLimit = 50
|
||||||
)
|
)
|
||||||
|
|
||||||
// redirectToApp issues a SeeOther redirect to the page for the given
|
|
||||||
// app ID, with an optional suffix such as "/deployments" or
|
|
||||||
// "?success=updated". The ID is path-escaped so the target is always
|
|
||||||
// a relative application URL.
|
|
||||||
func redirectToApp(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
appID, suffix string,
|
|
||||||
) {
|
|
||||||
http.Redirect(
|
|
||||||
writer,
|
|
||||||
request,
|
|
||||||
"/apps/"+url.PathEscape(appID)+suffix,
|
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleAppNew returns the new app form handler.
|
// HandleAppNew returns the new app form handler.
|
||||||
func (h *Handlers) HandleAppNew() http.HandlerFunc {
|
func (h *Handlers) HandleAppNew() http.HandlerFunc {
|
||||||
tmpl := templates.GetParsed()
|
tmpl := templates.GetParsed()
|
||||||
@@ -58,9 +39,7 @@ func (h *Handlers) HandleAppNew() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleAppCreate handles app creation.
|
// HandleAppCreate handles app creation.
|
||||||
//
|
func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
|
||||||
//nolint:funlen // validation adds necessary length
|
|
||||||
func (h *Handlers) HandleAppCreate() http.HandlerFunc {
|
|
||||||
tmpl := templates.GetParsed()
|
tmpl := templates.GetParsed()
|
||||||
|
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
@@ -75,18 +54,12 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc {
|
|||||||
repoURL := request.FormValue("repo_url")
|
repoURL := request.FormValue("repo_url")
|
||||||
branch := request.FormValue("branch")
|
branch := request.FormValue("branch")
|
||||||
dockerfilePath := request.FormValue("dockerfile_path")
|
dockerfilePath := request.FormValue("dockerfile_path")
|
||||||
dockerNetwork := request.FormValue("docker_network")
|
|
||||||
ntfyTopic := request.FormValue("ntfy_topic")
|
|
||||||
slackWebhook := request.FormValue("slack_webhook")
|
|
||||||
|
|
||||||
data := h.addGlobals(map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
"Name": name,
|
"Name": name,
|
||||||
"RepoURL": repoURL,
|
"RepoURL": repoURL,
|
||||||
"Branch": branch,
|
"Branch": branch,
|
||||||
"DockerfilePath": dockerfilePath,
|
"DockerfilePath": dockerfilePath,
|
||||||
"DockerNetwork": dockerNetwork,
|
|
||||||
"NtfyTopic": ntfyTopic,
|
|
||||||
"SlackWebhook": slackWebhook,
|
|
||||||
}, request)
|
}, request)
|
||||||
|
|
||||||
if name == "" || repoURL == "" {
|
if name == "" || repoURL == "" {
|
||||||
@@ -99,15 +72,7 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc {
|
|||||||
nameErr := validateAppName(name)
|
nameErr := validateAppName(name)
|
||||||
if nameErr != nil {
|
if nameErr != nil {
|
||||||
data["Error"] = "Invalid app name: " + nameErr.Error()
|
data["Error"] = "Invalid app name: " + nameErr.Error()
|
||||||
h.renderTemplate(writer, tmpl, "app_new.html", data)
|
_ = tmpl.ExecuteTemplate(writer, "app_new.html", data)
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
repoURLErr := validateRepoURL(repoURL)
|
|
||||||
if repoURLErr != nil {
|
|
||||||
data["Error"] = "Invalid repository URL: " + repoURLErr.Error()
|
|
||||||
h.renderTemplate(writer, tmpl, "app_new.html", data)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -127,9 +92,6 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc {
|
|||||||
RepoURL: repoURL,
|
RepoURL: repoURL,
|
||||||
Branch: branch,
|
Branch: branch,
|
||||||
DockerfilePath: dockerfilePath,
|
DockerfilePath: dockerfilePath,
|
||||||
DockerNetwork: dockerNetwork,
|
|
||||||
NtfyTopic: ntfyTopic,
|
|
||||||
SlackWebhook: slackWebhook,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
@@ -181,14 +143,10 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
webhookURL := "https://" + request.Host + "/webhook/" + application.WebhookSecret
|
webhookURL := "https://" + request.Host + "/webhook/" + application.WebhookSecret
|
||||||
deployKey := formatDeployKey(
|
deployKey := formatDeployKey(application.SSHPublicKey, application.CreatedAt, application.Name)
|
||||||
application.SSHPublicKey,
|
|
||||||
application.CreatedAt,
|
|
||||||
application.Name,
|
|
||||||
)
|
|
||||||
|
|
||||||
data := h.addGlobals(map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
dataKeyApp: application,
|
"App": application,
|
||||||
"EnvVars": envVars,
|
"EnvVars": envVars,
|
||||||
"Labels": labels,
|
"Labels": labels,
|
||||||
"Volumes": volumes,
|
"Volumes": volumes,
|
||||||
@@ -226,7 +184,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := h.addGlobals(map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
dataKeyApp: application,
|
"App": application,
|
||||||
}, request)
|
}, request)
|
||||||
|
|
||||||
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
||||||
@@ -234,7 +192,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleAppUpdate handles app updates.
|
// HandleAppUpdate handles app updates.
|
||||||
func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
|
func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
|
||||||
tmpl := templates.GetParsed()
|
tmpl := templates.GetParsed()
|
||||||
|
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
@@ -259,21 +217,10 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
|
|||||||
nameErr := validateAppName(newName)
|
nameErr := validateAppName(newName)
|
||||||
if nameErr != nil {
|
if nameErr != nil {
|
||||||
data := h.addGlobals(map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
dataKeyApp: application,
|
"App": application,
|
||||||
dataKeyError: "Invalid app name: " + nameErr.Error(),
|
"Error": "Invalid app name: " + nameErr.Error(),
|
||||||
}, request)
|
}, request)
|
||||||
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
repoURLErr := validateRepoURL(request.FormValue("repo_url"))
|
|
||||||
if repoURLErr != nil {
|
|
||||||
data := h.addGlobals(map[string]any{
|
|
||||||
dataKeyApp: application,
|
|
||||||
dataKeyError: "Invalid repository URL: " + repoURLErr.Error(),
|
|
||||||
}, request)
|
|
||||||
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -282,19 +229,23 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
|
|||||||
application.RepoURL = request.FormValue("repo_url")
|
application.RepoURL = request.FormValue("repo_url")
|
||||||
application.Branch = request.FormValue("branch")
|
application.Branch = request.FormValue("branch")
|
||||||
application.DockerfilePath = request.FormValue("dockerfile_path")
|
application.DockerfilePath = request.FormValue("dockerfile_path")
|
||||||
application.DockerNetwork = optionalNullString(request.FormValue("docker_network"))
|
|
||||||
application.NtfyTopic = optionalNullString(request.FormValue("ntfy_topic"))
|
|
||||||
application.SlackWebhook = optionalNullString(request.FormValue("slack_webhook"))
|
|
||||||
|
|
||||||
limitsErr := applyResourceLimits(application, request)
|
if network := request.FormValue("docker_network"); network != "" {
|
||||||
if limitsErr != "" {
|
application.DockerNetwork = sql.NullString{String: network, Valid: true}
|
||||||
data := h.addGlobals(map[string]any{
|
} else {
|
||||||
dataKeyApp: application,
|
application.DockerNetwork = sql.NullString{}
|
||||||
dataKeyError: limitsErr,
|
}
|
||||||
}, request)
|
|
||||||
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
|
||||||
|
|
||||||
return
|
if ntfy := request.FormValue("ntfy_topic"); ntfy != "" {
|
||||||
|
application.NtfyTopic = sql.NullString{String: ntfy, Valid: true}
|
||||||
|
} else {
|
||||||
|
application.NtfyTopic = sql.NullString{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if slack := request.FormValue("slack_webhook"); slack != "" {
|
||||||
|
application.SlackWebhook = sql.NullString{String: slack, Valid: true}
|
||||||
|
} else {
|
||||||
|
application.SlackWebhook = sql.NullString{}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveErr := application.Save(request.Context())
|
saveErr := application.Save(request.Context())
|
||||||
@@ -302,15 +253,16 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
|
|||||||
h.log.Error("failed to update app", "error", saveErr)
|
h.log.Error("failed to update app", "error", saveErr)
|
||||||
|
|
||||||
data := h.addGlobals(map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
dataKeyApp: application,
|
"App": application,
|
||||||
dataKeyError: "Failed to update app",
|
"Error": "Failed to update app",
|
||||||
}, request)
|
}, request)
|
||||||
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToApp(writer, request, application.ID, "?success=updated")
|
redirectURL := "/apps/" + application.ID + "?success=updated"
|
||||||
|
http.Redirect(writer, request, redirectURL, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +347,12 @@ func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}(deployCtx, application)
|
}(deployCtx, application)
|
||||||
|
|
||||||
redirectToApp(writer, request, application.ID, "/deployments")
|
http.Redirect(
|
||||||
|
writer,
|
||||||
|
request,
|
||||||
|
"/apps/"+application.ID+"/deployments",
|
||||||
|
http.StatusSeeOther,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,7 +373,12 @@ func (h *Handlers) HandleCancelDeploy() http.HandlerFunc {
|
|||||||
h.log.Info("deployment cancelled by user", "app", application.Name)
|
h.log.Info("deployment cancelled by user", "app", application.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToApp(writer, request, application.ID, "")
|
http.Redirect(
|
||||||
|
writer,
|
||||||
|
request,
|
||||||
|
"/apps/"+application.ID,
|
||||||
|
http.StatusSeeOther,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,12 +397,12 @@ func (h *Handlers) HandleAppRollback() http.HandlerFunc {
|
|||||||
rollbackErr := h.deploy.Rollback(request.Context(), application)
|
rollbackErr := h.deploy.Rollback(request.Context(), application)
|
||||||
if rollbackErr != nil {
|
if rollbackErr != nil {
|
||||||
h.log.Error("rollback failed", "error", rollbackErr, "app", application.Name)
|
h.log.Error("rollback failed", "error", rollbackErr, "app", application.Name)
|
||||||
redirectToApp(writer, request, application.ID, "")
|
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToApp(writer, request, application.ID, "?success=rolledback")
|
http.Redirect(writer, request, "/apps/"+application.ID+"?success=rolledback", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,7 +426,7 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
|
|
||||||
data := h.addGlobals(map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
dataKeyApp: application,
|
"App": application,
|
||||||
"Deployments": deployments,
|
"Deployments": deployments,
|
||||||
}, request)
|
}, request)
|
||||||
|
|
||||||
@@ -537,7 +499,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- output sanitized
|
_, _ = writer.Write([]byte(logs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,12 +534,12 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
|
|||||||
|
|
||||||
logs := ""
|
logs := ""
|
||||||
if deployment.Logs.Valid {
|
if deployment.Logs.Valid {
|
||||||
logs = SanitizeLogs(deployment.Logs.String)
|
logs = deployment.Logs.String
|
||||||
}
|
}
|
||||||
|
|
||||||
response := map[string]any{
|
response := map[string]any{
|
||||||
jsonKeyLogs: logs,
|
"logs": logs,
|
||||||
jsonKeyStatus: deployment.Status,
|
"status": deployment.Status,
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = json.NewEncoder(writer).Encode(response)
|
_ = json.NewEncoder(writer).Encode(response)
|
||||||
@@ -619,8 +581,8 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists — logPath is constructed internally, not from user input
|
// Check if file exists
|
||||||
_, err := os.Stat(logPath) // #nosec G703 -- internal path, not user input
|
_, err := os.Stat(logPath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
http.NotFound(writer, request)
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
@@ -640,7 +602,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
|
|||||||
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||||
|
|
||||||
http.ServeFile(writer, request, logPath) // #nosec G703 -- internal path
|
http.ServeFile(writer, request, logPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,8 +626,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
|
|||||||
containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID)
|
containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID)
|
||||||
if containerErr != nil || containerInfo == nil {
|
if containerErr != nil || containerInfo == nil {
|
||||||
response := map[string]any{
|
response := map[string]any{
|
||||||
jsonKeyLogs: "No container running\n",
|
"logs": "No container running\n",
|
||||||
jsonKeyStatus: "stopped",
|
"status": "stopped",
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(writer).Encode(response)
|
_ = json.NewEncoder(writer).Encode(response)
|
||||||
|
|
||||||
@@ -685,8 +647,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
|
|
||||||
response := map[string]any{
|
response := map[string]any{
|
||||||
jsonKeyLogs: "Failed to fetch container logs\n",
|
"logs": "Failed to fetch container logs\n",
|
||||||
jsonKeyStatus: "error",
|
"status": "error",
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(writer).Encode(response)
|
_ = json.NewEncoder(writer).Encode(response)
|
||||||
|
|
||||||
@@ -699,8 +661,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response := map[string]any{
|
response := map[string]any{
|
||||||
jsonKeyLogs: SanitizeLogs(logs),
|
"logs": logs,
|
||||||
jsonKeyStatus: status,
|
"status": status,
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = json.NewEncoder(writer).Encode(response)
|
_ = json.NewEncoder(writer).Encode(response)
|
||||||
@@ -734,7 +696,7 @@ func (h *Handlers) HandleAppStatusAPI() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response := map[string]any{
|
response := map[string]any{
|
||||||
jsonKeyStatus: string(application.Status),
|
"status": string(application.Status),
|
||||||
"latestDeploymentID": latestDeploymentID,
|
"latestDeploymentID": latestDeploymentID,
|
||||||
"latestDeploymentStatus": latestDeploymentStatus,
|
"latestDeploymentStatus": latestDeploymentStatus,
|
||||||
}
|
}
|
||||||
@@ -771,7 +733,7 @@ func (h *Handlers) HandleRecentDeploymentsAPI() http.HandlerFunc {
|
|||||||
for _, d := range deployments {
|
for _, d := range deployments {
|
||||||
deploymentsData = append(deploymentsData, map[string]any{
|
deploymentsData = append(deploymentsData, map[string]any{
|
||||||
"id": d.ID,
|
"id": d.ID,
|
||||||
jsonKeyStatus: string(d.Status),
|
"status": string(d.Status),
|
||||||
"duration": d.Duration(),
|
"duration": d.Duration(),
|
||||||
"shortCommit": d.ShortCommit(),
|
"shortCommit": d.ShortCommit(),
|
||||||
"finishedAtISO": d.FinishedAtISO(),
|
"finishedAtISO": d.FinishedAtISO(),
|
||||||
@@ -813,7 +775,7 @@ func (h *Handlers) handleContainerAction(
|
|||||||
|
|
||||||
containerInfo, containerErr := h.docker.FindContainerByAppID(ctx, appID)
|
containerInfo, containerErr := h.docker.FindContainerByAppID(ctx, appID)
|
||||||
if containerErr != nil || containerInfo == nil {
|
if containerErr != nil || containerInfo == nil {
|
||||||
redirectToApp(writer, request, appID, "")
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -846,7 +808,7 @@ func (h *Handlers) handleContainerAction(
|
|||||||
"action", action, "app", application.Name, "container", containerID)
|
"action", action, "app", application.Name, "container", containerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToApp(writer, request, appID, "")
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleAppRestart handles restarting an app's container.
|
// HandleAppRestart handles restarting an app's container.
|
||||||
@@ -900,7 +862,7 @@ func (h *Handlers) addKeyValueToApp(
|
|||||||
value := request.FormValue("value")
|
value := request.FormValue("value")
|
||||||
|
|
||||||
if key == "" || value == "" {
|
if key == "" || value == "" {
|
||||||
redirectToApp(writer, request, application.ID, "")
|
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -910,95 +872,53 @@ func (h *Handlers) addKeyValueToApp(
|
|||||||
h.log.Error("failed to add key-value pair", "error", saveErr)
|
h.log.Error("failed to add key-value pair", "error", saveErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToApp(writer, request, application.ID, "")
|
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// envPairJSON represents a key-value pair in the JSON request body.
|
// HandleEnvVarAdd handles adding an environment variable.
|
||||||
type envPairJSON struct {
|
func (h *Handlers) HandleEnvVarAdd() http.HandlerFunc {
|
||||||
Key string `json:"key"`
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
Value string `json:"value"`
|
h.addKeyValueToApp(
|
||||||
|
writer,
|
||||||
|
request,
|
||||||
|
func(ctx context.Context, application *models.App, key, value string) error {
|
||||||
|
envVar := models.NewEnvVar(h.db)
|
||||||
|
envVar.AppID = application.ID
|
||||||
|
envVar.Key = key
|
||||||
|
envVar.Value = value
|
||||||
|
|
||||||
|
return envVar.Save(ctx)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// envVarMaxBodyBytes is the maximum allowed request body size for env var saves (1 MB).
|
// HandleEnvVarDelete handles deleting an environment variable.
|
||||||
const envVarMaxBodyBytes = 1 << 20
|
func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc {
|
||||||
|
|
||||||
// validateEnvPairs validates env var pairs.
|
|
||||||
// It rejects empty keys and duplicate keys (returns a non-empty error string).
|
|
||||||
func validateEnvPairs(pairs []envPairJSON) ([]models.EnvVarPair, string) {
|
|
||||||
seen := make(map[string]bool, len(pairs))
|
|
||||||
|
|
||||||
result := make([]models.EnvVarPair, 0, len(pairs))
|
|
||||||
|
|
||||||
for _, p := range pairs {
|
|
||||||
trimmedKey := strings.TrimSpace(p.Key)
|
|
||||||
if trimmedKey == "" {
|
|
||||||
return nil, "empty environment variable key is not allowed"
|
|
||||||
}
|
|
||||||
|
|
||||||
if seen[trimmedKey] {
|
|
||||||
return nil, "duplicate environment variable key: " + trimmedKey
|
|
||||||
}
|
|
||||||
|
|
||||||
seen[trimmedKey] = true
|
|
||||||
|
|
||||||
result = append(result, models.EnvVarPair{Key: trimmedKey, Value: p.Value})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleEnvVarSave handles bulk saving of all environment variables.
|
|
||||||
// It reads a JSON array of {key, value} objects from the request body,
|
|
||||||
// deletes all existing env vars for the app, and inserts the full
|
|
||||||
// submitted set atomically within a database transaction.
|
|
||||||
// Duplicate keys are rejected with a 400 Bad Request error.
|
|
||||||
func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
|
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
appID := chi.URLParam(request, "id")
|
appID := chi.URLParam(request, "id")
|
||||||
|
envVarIDStr := chi.URLParam(request, "envID")
|
||||||
|
|
||||||
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
|
||||||
if findErr != nil || application == nil {
|
if parseErr != nil {
|
||||||
http.NotFound(writer, request)
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit request body size to prevent abuse
|
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
|
||||||
request.Body = http.MaxBytesReader(writer, request.Body, envVarMaxBodyBytes)
|
if findErr != nil || envVar == nil || envVar.AppID != appID {
|
||||||
|
http.NotFound(writer, request)
|
||||||
var pairs []envPairJSON
|
|
||||||
|
|
||||||
decodeErr := json.NewDecoder(request.Body).Decode(&pairs)
|
|
||||||
if decodeErr != nil {
|
|
||||||
h.respondJSON(writer, request, map[string]string{
|
|
||||||
jsonKeyError: "invalid request body",
|
|
||||||
}, http.StatusBadRequest)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
modelPairs, validationErr := validateEnvPairs(pairs)
|
deleteErr := envVar.Delete(request.Context())
|
||||||
if validationErr != "" {
|
if deleteErr != nil {
|
||||||
h.respondJSON(writer, request, map[string]string{
|
h.log.Error("failed to delete env var", "error", deleteErr)
|
||||||
jsonKeyError: validationErr,
|
|
||||||
}, http.StatusBadRequest)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceErr := models.ReplaceEnvVarsByAppID(
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||||
request.Context(), h.db, application.ID, modelPairs,
|
|
||||||
)
|
|
||||||
if replaceErr != nil {
|
|
||||||
h.log.Error("failed to replace env vars", "error", replaceErr)
|
|
||||||
h.respondJSON(writer, request, map[string]string{
|
|
||||||
jsonKeyError: "failed to save environment variables",
|
|
||||||
}, http.StatusInternalServerError)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.respondJSON(writer, request, map[string]bool{"ok": true}, http.StatusOK)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1020,77 +940,32 @@ func (h *Handlers) HandleLabelAdd() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteAppResource handles deletion of an app-owned resource (label,
|
// HandleLabelDelete handles deleting a label.
|
||||||
// volume, or port) identified by an int64 URL parameter. The
|
func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
|
||||||
// deleteByID closure reports whether the resource was found to belong
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
// to the app, and returns the deletion error if one occurred.
|
|
||||||
func (h *Handlers) deleteAppResource(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
idParam, logName string,
|
|
||||||
deleteByID deleteByIDFunc,
|
|
||||||
) {
|
|
||||||
appID := chi.URLParam(request, "id")
|
appID := chi.URLParam(request, "id")
|
||||||
idStr := chi.URLParam(request, idParam)
|
labelIDStr := chi.URLParam(request, "labelID")
|
||||||
|
|
||||||
id, parseErr := strconv.ParseInt(idStr, 10, 64)
|
labelID, parseErr := strconv.ParseInt(labelIDStr, 10, 64)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
http.NotFound(writer, request)
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
found, deleteErr := deleteByID(request.Context(), appID, id)
|
label, findErr := models.FindLabel(request.Context(), h.db, labelID)
|
||||||
if !found {
|
if findErr != nil || label == nil || label.AppID != appID {
|
||||||
http.NotFound(writer, request)
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteErr := label.Delete(request.Context())
|
||||||
if deleteErr != nil {
|
if deleteErr != nil {
|
||||||
h.log.Error("failed to delete "+logName, "error", deleteErr)
|
h.log.Error("failed to delete label", "error", deleteErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToApp(writer, request, appID, "")
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||||
}
|
|
||||||
|
|
||||||
// deleteByIDFunc looks up an app-owned resource by ID and deletes it
|
|
||||||
// when it belongs to the given app. It reports whether the resource
|
|
||||||
// was found, and returns lookup or deletion errors.
|
|
||||||
type deleteByIDFunc func(ctx context.Context, appID string, id int64) (bool, error)
|
|
||||||
|
|
||||||
// makeDeleteByID builds a deleteByIDFunc from a model's find
|
|
||||||
// function, its app-ID accessor, and its delete method.
|
|
||||||
func makeDeleteByID[T any](
|
|
||||||
db *database.Database,
|
|
||||||
find func(context.Context, *database.Database, int64) (*T, error),
|
|
||||||
appIDOf func(*T) string,
|
|
||||||
del func(*T, context.Context) error,
|
|
||||||
) deleteByIDFunc {
|
|
||||||
return func(ctx context.Context, appID string, id int64) (bool, error) {
|
|
||||||
resource, findErr := find(ctx, db, id)
|
|
||||||
if findErr != nil {
|
|
||||||
return false, findErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if resource == nil || appIDOf(resource) != appID {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, del(resource, ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleLabelDelete handles deleting a label.
|
|
||||||
func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
|
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
h.deleteAppResource(
|
|
||||||
writer, request, "labelID", "label",
|
|
||||||
makeDeleteByID(h.db, models.FindLabel,
|
|
||||||
func(l *models.Label) string { return l.AppID },
|
|
||||||
(*models.Label).Delete,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1118,15 +993,12 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
|
|||||||
readOnly := request.FormValue("readonly") == "1"
|
readOnly := request.FormValue("readonly") == "1"
|
||||||
|
|
||||||
if hostPath == "" || containerPath == "" {
|
if hostPath == "" || containerPath == "" {
|
||||||
redirectToApp(writer, request, application.ID, "")
|
http.Redirect(
|
||||||
|
writer,
|
||||||
return
|
request,
|
||||||
}
|
"/apps/"+application.ID,
|
||||||
|
http.StatusSeeOther,
|
||||||
pathErr := validateVolumePaths(hostPath, containerPath)
|
)
|
||||||
if pathErr != nil {
|
|
||||||
h.log.Error("invalid volume path", "error", pathErr)
|
|
||||||
redirectToApp(writer, request, application.ID, "")
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1142,20 +1014,36 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
|
|||||||
h.log.Error("failed to add volume", "error", saveErr)
|
h.log.Error("failed to add volume", "error", saveErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToApp(writer, request, application.ID, "")
|
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleVolumeDelete handles deleting a volume mount.
|
// HandleVolumeDelete handles deleting a volume mount.
|
||||||
func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
|
func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
h.deleteAppResource(
|
appID := chi.URLParam(request, "id")
|
||||||
writer, request, "volumeID", "volume",
|
volumeIDStr := chi.URLParam(request, "volumeID")
|
||||||
makeDeleteByID(h.db, models.FindVolume,
|
|
||||||
func(v *models.Volume) string { return v.AppID },
|
volumeID, parseErr := strconv.ParseInt(volumeIDStr, 10, 64)
|
||||||
(*models.Volume).Delete,
|
if parseErr != nil {
|
||||||
),
|
http.NotFound(writer, request)
|
||||||
)
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
volume, findErr := models.FindVolume(request.Context(), h.db, volumeID)
|
||||||
|
if findErr != nil || volume == nil || volume.AppID != appID {
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteErr := volume.Delete(request.Context())
|
||||||
|
if deleteErr != nil {
|
||||||
|
h.log.Error("failed to delete volume", "error", deleteErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1183,7 +1071,7 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
|
|||||||
request.FormValue("container_port"),
|
request.FormValue("container_port"),
|
||||||
)
|
)
|
||||||
if !valid {
|
if !valid {
|
||||||
redirectToApp(writer, request, application.ID, "")
|
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1204,7 +1092,7 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
|
|||||||
h.log.Error("failed to save port", "error", saveErr)
|
h.log.Error("failed to save port", "error", saveErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToApp(writer, request, application.ID, "")
|
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1228,13 +1116,29 @@ func parsePortValues(hostPortStr, containerPortStr string) (int, int, bool) {
|
|||||||
// HandlePortDelete handles deleting a port mapping.
|
// HandlePortDelete handles deleting a port mapping.
|
||||||
func (h *Handlers) HandlePortDelete() http.HandlerFunc {
|
func (h *Handlers) HandlePortDelete() http.HandlerFunc {
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
h.deleteAppResource(
|
appID := chi.URLParam(request, "id")
|
||||||
writer, request, "portID", "port",
|
portIDStr := chi.URLParam(request, "portID")
|
||||||
makeDeleteByID(h.db, models.FindPort,
|
|
||||||
func(p *models.Port) string { return p.AppID },
|
portID, parseErr := strconv.ParseInt(portIDStr, 10, 64)
|
||||||
(*models.Port).Delete,
|
if parseErr != nil {
|
||||||
),
|
http.NotFound(writer, request)
|
||||||
)
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
port, findErr := models.FindPort(request.Context(), h.db, portID)
|
||||||
|
if findErr != nil || port == nil || port.AppID != appID {
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteErr := port.Delete(request.Context())
|
||||||
|
if deleteErr != nil {
|
||||||
|
h.log.Error("failed to delete port", "error", deleteErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1265,6 +1169,59 @@ func ValidateVolumePath(p string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleEnvVarEdit handles editing an existing environment variable.
|
||||||
|
func (h *Handlers) HandleEnvVarEdit() http.HandlerFunc {
|
||||||
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
appID := chi.URLParam(request, "id")
|
||||||
|
envVarIDStr := chi.URLParam(request, "varID")
|
||||||
|
|
||||||
|
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
|
||||||
|
if parseErr != nil {
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
|
||||||
|
if findErr != nil || envVar == nil || envVar.AppID != appID {
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
formErr := request.ParseForm()
|
||||||
|
if formErr != nil {
|
||||||
|
http.Error(writer, "Bad Request", http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := request.FormValue("key")
|
||||||
|
value := request.FormValue("value")
|
||||||
|
|
||||||
|
if key == "" || value == "" {
|
||||||
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
envVar.Key = key
|
||||||
|
envVar.Value = value
|
||||||
|
|
||||||
|
saveErr := envVar.Save(request.Context())
|
||||||
|
if saveErr != nil {
|
||||||
|
h.log.Error("failed to update env var", "error", saveErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(
|
||||||
|
writer,
|
||||||
|
request,
|
||||||
|
"/apps/"+appID+"?success=env-updated",
|
||||||
|
http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HandleLabelEdit handles editing an existing label.
|
// HandleLabelEdit handles editing an existing label.
|
||||||
func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
|
func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
@@ -1296,7 +1253,7 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
|
|||||||
value := request.FormValue("value")
|
value := request.FormValue("value")
|
||||||
|
|
||||||
if key == "" || value == "" {
|
if key == "" || value == "" {
|
||||||
redirectToApp(writer, request, appID, "")
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1309,7 +1266,7 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
|
|||||||
h.log.Error("failed to update label", "error", saveErr)
|
h.log.Error("failed to update label", "error", saveErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToApp(writer, request, appID, "")
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1345,7 +1302,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
|
|||||||
readOnly := request.FormValue("readonly") == "1"
|
readOnly := request.FormValue("readonly") == "1"
|
||||||
|
|
||||||
if hostPath == "" || containerPath == "" {
|
if hostPath == "" || containerPath == "" {
|
||||||
redirectToApp(writer, request, appID, "")
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1353,7 +1310,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
|
|||||||
pathErr := validateVolumePaths(hostPath, containerPath)
|
pathErr := validateVolumePaths(hostPath, containerPath)
|
||||||
if pathErr != nil {
|
if pathErr != nil {
|
||||||
h.log.Error("invalid volume path", "error", pathErr)
|
h.log.Error("invalid volume path", "error", pathErr)
|
||||||
redirectToApp(writer, request, appID, "")
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1367,7 +1324,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
|
|||||||
h.log.Error("failed to update volume", "error", saveErr)
|
h.log.Error("failed to update volume", "error", saveErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToApp(writer, request, appID, "")
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1386,132 +1343,6 @@ func validateVolumePaths(hostPath, containerPath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrInvalidMemoryFormat is returned when a memory limit string cannot be parsed.
|
|
||||||
var ErrInvalidMemoryFormat = errors.New(
|
|
||||||
"must be a number with optional unit suffix (e.g. 256m, 1g, 512000000)",
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrNegativeValue is returned when a resource limit is negative.
|
|
||||||
var ErrNegativeValue = errors.New("value must be positive")
|
|
||||||
|
|
||||||
// Memory unit byte multipliers.
|
|
||||||
const (
|
|
||||||
kilobyte = 1024
|
|
||||||
megabyte = 1024 * 1024
|
|
||||||
gigabyte = 1024 * 1024 * 1024
|
|
||||||
)
|
|
||||||
|
|
||||||
// optionalNullString converts a form value to a sql.NullString.
|
|
||||||
// Returns a valid NullString if non-empty, invalid (NULL) if empty.
|
|
||||||
func optionalNullString(s string) sql.NullString {
|
|
||||||
if s != "" {
|
|
||||||
return sql.NullString{String: s, Valid: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sql.NullString{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyResourceLimits parses CPU and memory limit form values and
|
|
||||||
// applies them to the app. Returns an error message string if
|
|
||||||
// validation fails, or empty string on success.
|
|
||||||
func applyResourceLimits(application *models.App, request *http.Request) string {
|
|
||||||
cpuLimit, cpuErr := parseOptionalFloat64(request.FormValue("cpu_limit"))
|
|
||||||
if cpuErr != nil {
|
|
||||||
return "Invalid CPU limit: must be a positive number (e.g. 0.5, 1, 2)"
|
|
||||||
}
|
|
||||||
|
|
||||||
application.CPULimit = cpuLimit
|
|
||||||
|
|
||||||
memoryLimit, memErr := parseOptionalMemoryBytes(request.FormValue("memory_limit"))
|
|
||||||
if memErr != nil {
|
|
||||||
return "Invalid memory limit: " + memErr.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
application.MemoryLimit = memoryLimit
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// memoryUnitMultiplier returns the byte multiplier for a memory unit suffix.
|
|
||||||
// Returns 0 if the suffix is not recognized.
|
|
||||||
func memoryUnitMultiplier(suffix byte) int64 {
|
|
||||||
switch suffix {
|
|
||||||
case 'k':
|
|
||||||
return kilobyte
|
|
||||||
case 'm':
|
|
||||||
return megabyte
|
|
||||||
case 'g':
|
|
||||||
return gigabyte
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseOptionalFloat64 parses an optional float64 form field.
|
|
||||||
// Returns a valid NullFloat64 if the string is non-empty and parses
|
|
||||||
// to a positive number.
|
|
||||||
// Returns an empty NullFloat64 if the string is empty.
|
|
||||||
// Returns an error if the string is non-empty but invalid or non-positive.
|
|
||||||
func parseOptionalFloat64(s string) (sql.NullFloat64, error) {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if s == "" {
|
|
||||||
return sql.NullFloat64{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
val, err := strconv.ParseFloat(s, 64)
|
|
||||||
if err != nil {
|
|
||||||
return sql.NullFloat64{}, fmt.Errorf("invalid number: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if val <= 0 {
|
|
||||||
return sql.NullFloat64{}, ErrNegativeValue
|
|
||||||
}
|
|
||||||
|
|
||||||
return sql.NullFloat64{Float64: val, Valid: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseOptionalMemoryBytes parses an optional memory limit string into bytes.
|
|
||||||
// Accepts plain bytes (e.g. "536870912") or suffixed values
|
|
||||||
// (e.g. "512m", "1g", "256k").
|
|
||||||
// Returns a valid NullInt64 with bytes if non-empty, empty NullInt64 if blank.
|
|
||||||
func parseOptionalMemoryBytes(s string) (sql.NullInt64, error) {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if s == "" {
|
|
||||||
return sql.NullInt64{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
s = strings.ToLower(s)
|
|
||||||
|
|
||||||
// Check for unit suffix
|
|
||||||
multiplier := memoryUnitMultiplier(s[len(s)-1])
|
|
||||||
if multiplier > 0 {
|
|
||||||
numStr := s[:len(s)-1]
|
|
||||||
|
|
||||||
val, err := strconv.ParseFloat(numStr, 64)
|
|
||||||
if err != nil {
|
|
||||||
return sql.NullInt64{}, ErrInvalidMemoryFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
if val <= 0 {
|
|
||||||
return sql.NullInt64{}, ErrNegativeValue
|
|
||||||
}
|
|
||||||
|
|
||||||
return sql.NullInt64{Int64: int64(val * float64(multiplier)), Valid: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plain bytes
|
|
||||||
val, err := strconv.ParseInt(s, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return sql.NullInt64{}, ErrInvalidMemoryFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
if val <= 0 {
|
|
||||||
return sql.NullInt64{}, ErrNegativeValue
|
|
||||||
}
|
|
||||||
|
|
||||||
return sql.NullInt64{Int64: val, Valid: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatDeployKey formats an SSH public key with a descriptive comment.
|
// formatDeployKey formats an SSH public key with a descriptive comment.
|
||||||
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
|
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
|
||||||
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {
|
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {
|
||||||
|
|||||||
@@ -21,16 +21,8 @@ func TestValidateAppName(t *testing.T) {
|
|||||||
{"empty", "", true},
|
{"empty", "", true},
|
||||||
{"single char", "a", true},
|
{"single char", "a", true},
|
||||||
{"too long", "a" + string(make([]byte, 63)), true},
|
{"too long", "a" + string(make([]byte, 63)), true},
|
||||||
{
|
{"exactly 63 chars", "a23456789012345678901234567890123456789012345678901234567890123", false},
|
||||||
"exactly 63 chars",
|
{"64 chars", "a234567890123456789012345678901234567890123456789012345678901234", true},
|
||||||
"a23456789012345678901234567890123456789012345678901234567890123",
|
|
||||||
false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"64 chars",
|
|
||||||
"a234567890123456789012345678901234567890123456789012345678901234",
|
|
||||||
true,
|
|
||||||
},
|
|
||||||
{"uppercase", "MyApp", true},
|
{"uppercase", "MyApp", true},
|
||||||
{"spaces", "my app", true},
|
{"spaces", "my app", true},
|
||||||
{"starts with hyphen", "-myapp", true},
|
{"starts with hyphen", "-myapp", true},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/templates"
|
"git.eeqj.de/sneak/upaas/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleLoginGET returns the login page handler.
|
// HandleLoginGET returns the login page handler.
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/models"
|
"git.eeqj.de/sneak/upaas/internal/models"
|
||||||
"sneak.berlin/go/upaas/templates"
|
"git.eeqj.de/sneak/upaas/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppStats holds deployment statistics for an app.
|
// AppStats holds deployment statistics for an app.
|
||||||
|
|||||||
@@ -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"
|
"github.com/gorilla/csrf"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
"sneak.berlin/go/upaas/internal/docker"
|
"git.eeqj.de/sneak/upaas/internal/docker"
|
||||||
"sneak.berlin/go/upaas/internal/globals"
|
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||||
"sneak.berlin/go/upaas/internal/healthcheck"
|
"git.eeqj.de/sneak/upaas/internal/healthcheck"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/service/app"
|
"git.eeqj.de/sneak/upaas/internal/service/app"
|
||||||
"sneak.berlin/go/upaas/internal/service/auth"
|
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
||||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||||
"sneak.berlin/go/upaas/internal/service/webhook"
|
"git.eeqj.de/sneak/upaas/internal/service/webhook"
|
||||||
"sneak.berlin/go/upaas/templates"
|
"git.eeqj.de/sneak/upaas/templates"
|
||||||
)
|
|
||||||
|
|
||||||
// Template data keys shared across handlers.
|
|
||||||
const (
|
|
||||||
dataKeyApp = "App"
|
|
||||||
dataKeyError = "Error"
|
|
||||||
)
|
|
||||||
|
|
||||||
// JSON response keys shared across handlers.
|
|
||||||
const (
|
|
||||||
jsonKeyError = "error"
|
|
||||||
jsonKeyLogs = "logs"
|
|
||||||
jsonKeyStatus = "status"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params contains dependencies for Handlers.
|
// Params contains dependencies for Handlers.
|
||||||
|
|||||||
@@ -15,26 +15,21 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/models"
|
"git.eeqj.de/sneak/upaas/internal/models"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
"sneak.berlin/go/upaas/internal/docker"
|
"git.eeqj.de/sneak/upaas/internal/docker"
|
||||||
"sneak.berlin/go/upaas/internal/globals"
|
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||||
"sneak.berlin/go/upaas/internal/handlers"
|
"git.eeqj.de/sneak/upaas/internal/handlers"
|
||||||
"sneak.berlin/go/upaas/internal/healthcheck"
|
"git.eeqj.de/sneak/upaas/internal/healthcheck"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/middleware"
|
"git.eeqj.de/sneak/upaas/internal/middleware"
|
||||||
"sneak.berlin/go/upaas/internal/service/app"
|
"git.eeqj.de/sneak/upaas/internal/service/app"
|
||||||
"sneak.berlin/go/upaas/internal/service/auth"
|
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
||||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||||
"sneak.berlin/go/upaas/internal/service/notify"
|
"git.eeqj.de/sneak/upaas/internal/service/notify"
|
||||||
"sneak.berlin/go/upaas/internal/service/webhook"
|
"git.eeqj.de/sneak/upaas/internal/service/webhook"
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
branchMain = "main"
|
|
||||||
paramSecret = "secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type testContext struct {
|
type testContext struct {
|
||||||
@@ -198,8 +193,7 @@ func TestHandleHealthCheck(t *testing.T) {
|
|||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
testCtx := setupTestHandlers(t)
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(
|
||||||
t.Context(),
|
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
"/.well-known/healthcheck.json",
|
"/.well-known/healthcheck.json",
|
||||||
nil,
|
nil,
|
||||||
@@ -216,26 +210,6 @@ func TestHandleHealthCheck(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertPageRenders serves a GET request for path with the given
|
|
||||||
// handler and asserts a 200 response containing want.
|
|
||||||
func assertPageRenders(
|
|
||||||
t *testing.T,
|
|
||||||
handler http.Handler,
|
|
||||||
path, want string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, path, nil,
|
|
||||||
)
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
||||||
assert.Contains(t, recorder.Body.String(), want)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleSetupGET(t *testing.T) {
|
func TestHandleSetupGET(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -243,7 +217,15 @@ func TestHandleSetupGET(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
testCtx := setupTestHandlers(t)
|
||||||
assertPageRenders(t, testCtx.handlers.HandleSetupGET(), "/setup", "setup")
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/setup", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler := testCtx.handlers.HandleSetupGET()
|
||||||
|
handler.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
assert.Contains(t, recorder.Body.String(), "setup")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,8 +237,7 @@ func createSetupFormRequest(
|
|||||||
form.Set("password", password)
|
form.Set("password", password)
|
||||||
form.Set("password_confirm", confirm)
|
form.Set("password_confirm", confirm)
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(
|
||||||
context.Background(),
|
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/setup",
|
"/setup",
|
||||||
strings.NewReader(form.Encode()),
|
strings.NewReader(form.Encode()),
|
||||||
@@ -333,7 +314,15 @@ func TestHandleLoginGET(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
testCtx := setupTestHandlers(t)
|
||||||
assertPageRenders(t, testCtx.handlers.HandleLoginGET(), "/login", "login")
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler := testCtx.handlers.HandleLoginGET()
|
||||||
|
handler.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
assert.Contains(t, recorder.Body.String(), "login")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,8 +331,7 @@ func createLoginFormRequest(username, password string) *http.Request {
|
|||||||
form.Set("username", username)
|
form.Set("username", username)
|
||||||
form.Set("password", password)
|
form.Set("password", password)
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(
|
||||||
context.Background(),
|
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/login",
|
"/login",
|
||||||
strings.NewReader(form.Encode()),
|
strings.NewReader(form.Encode()),
|
||||||
@@ -407,9 +395,7 @@ func TestHandleDashboard(t *testing.T) {
|
|||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
testCtx := setupTestHandlers(t)
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
t.Context(), http.MethodGet, "/", nil,
|
|
||||||
)
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
handler := testCtx.handlers.HandleDashboard()
|
handler := testCtx.handlers.HandleDashboard()
|
||||||
@@ -418,27 +404,6 @@ func TestHandleDashboard(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||||
assert.Contains(t, recorder.Body.String(), "Applications")
|
assert.Contains(t, recorder.Body.String(), "Applications")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("renders dashboard with apps without crashing on CSRFField", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
|
||||||
|
|
||||||
// Create an app so the template iterates over AppStats and hits .CSRFField
|
|
||||||
createTestApp(t, testCtx, "csrf-test-app")
|
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, "/", nil,
|
|
||||||
)
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := testCtx.handlers.HandleDashboard()
|
|
||||||
handler.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, recorder.Code,
|
|
||||||
"dashboard should not 500 when apps exist (CSRFField must be accessible)")
|
|
||||||
assert.Contains(t, recorder.Body.String(), "csrf-test-app")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleAppNew(t *testing.T) {
|
func TestHandleAppNew(t *testing.T) {
|
||||||
@@ -449,9 +414,7 @@ func TestHandleAppNew(t *testing.T) {
|
|||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
testCtx := setupTestHandlers(t)
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(http.MethodGet, "/apps/new", nil)
|
||||||
t.Context(), http.MethodGet, "/apps/new", nil,
|
|
||||||
)
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
handler := testCtx.handlers.HandleAppNew()
|
handler := testCtx.handlers.HandleAppNew()
|
||||||
@@ -490,7 +453,7 @@ func createTestApp(
|
|||||||
app.CreateAppInput{
|
app.CreateAppInput{
|
||||||
Name: name,
|
Name: name,
|
||||||
RepoURL: "git@example.com:user/" + name + ".git",
|
RepoURL: "git@example.com:user/" + name + ".git",
|
||||||
Branch: branchMain,
|
Branch: "main",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -511,7 +474,7 @@ func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
|
|||||||
app.CreateAppInput{
|
app.CreateAppInput{
|
||||||
Name: "oversize-test-app",
|
Name: "oversize-test-app",
|
||||||
RepoURL: "git@example.com:user/repo.git",
|
RepoURL: "git@example.com:user/repo.git",
|
||||||
Branch: branchMain,
|
Branch: "main",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
require.NoError(t, createErr)
|
require.NoError(t, createErr)
|
||||||
@@ -519,15 +482,14 @@ func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
|
|||||||
// Create a body larger than 1MB - it should be silently truncated
|
// Create a body larger than 1MB - it should be silently truncated
|
||||||
// and the webhook should still process (or fail gracefully on parse)
|
// and the webhook should still process (or fail gracefully on parse)
|
||||||
largePayload := strings.Repeat("x", 2*1024*1024) // 2MB
|
largePayload := strings.Repeat("x", 2*1024*1024) // 2MB
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/webhook/"+createdApp.WebhookSecret,
|
"/webhook/"+createdApp.WebhookSecret,
|
||||||
strings.NewReader(largePayload),
|
strings.NewReader(largePayload),
|
||||||
)
|
)
|
||||||
request = addChiURLParams(
|
request = addChiURLParams(
|
||||||
request,
|
request,
|
||||||
map[string]string{paramSecret: createdApp.WebhookSecret},
|
map[string]string{"secret": createdApp.WebhookSecret},
|
||||||
)
|
)
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
request.Header.Set("X-Gitea-Event", "push")
|
request.Header.Set("X-Gitea-Event", "push")
|
||||||
@@ -563,8 +525,7 @@ func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) {
|
|||||||
|
|
||||||
resourceID := cfg.createFn(t, testCtx, app1)
|
resourceID := cfg.createFn(t, testCtx, app1)
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
cfg.deletePath(app2.ID, resourceID),
|
cfg.deletePath(app2.ID, resourceID),
|
||||||
nil,
|
nil,
|
||||||
@@ -580,249 +541,45 @@ func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) {
|
|||||||
cfg.verifyFn(t, testCtx, resourceID)
|
cfg.verifyFn(t, testCtx, resourceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleEnvVarSaveBulk tests that HandleEnvVarSave replaces all env vars
|
// TestDeleteEnvVarOwnershipVerification tests that deleting an env var
|
||||||
// for an app with the submitted set (monolithic delete-all + insert-all).
|
// via another app's URL path returns 404 (IDOR prevention).
|
||||||
func TestHandleEnvVarSaveBulk(t *testing.T) {
|
func TestDeleteEnvVarOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
testOwnershipVerification(t, ownedResourceTestConfig{
|
||||||
createdApp := createTestApp(t, testCtx, "envvar-bulk-app")
|
appPrefix1: "envvar-owner-app",
|
||||||
|
appPrefix2: "envvar-other-app",
|
||||||
|
createFn: func(t *testing.T, tc *testContext, ownerApp *models.App) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
// Create some pre-existing env vars
|
envVar := models.NewEnvVar(tc.database)
|
||||||
for _, kv := range [][2]string{{"OLD_KEY", "old_value"}, {"REMOVE_ME", "gone"}} {
|
envVar.AppID = ownerApp.ID
|
||||||
ev := models.NewEnvVar(testCtx.database)
|
envVar.Key = "SECRET"
|
||||||
ev.AppID = createdApp.ID
|
envVar.Value = "hunter2"
|
||||||
ev.Key = kv[0]
|
require.NoError(t, envVar.Save(context.Background()))
|
||||||
ev.Value = kv[1]
|
|
||||||
require.NoError(t, ev.Save(context.Background()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Submit a new set as a JSON array of key/value objects
|
return envVar.ID
|
||||||
body := `[{"key":"NEW_KEY","value":"new_value"},{"key":"ANOTHER","value":"42"}]`
|
},
|
||||||
|
deletePath: func(appID string, resourceID int64) string {
|
||||||
|
return "/apps/" + appID + "/env/" + strconv.FormatInt(resourceID, 10) + "/delete"
|
||||||
|
},
|
||||||
|
chiParams: func(appID string, resourceID int64) map[string]string {
|
||||||
|
return map[string]string{"id": appID, "envID": strconv.FormatInt(resourceID, 10)}
|
||||||
|
},
|
||||||
|
handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleEnvVarDelete() },
|
||||||
|
verifyFn: func(t *testing.T, tc *testContext, resourceID int64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
r := chi.NewRouter()
|
found, findErr := models.FindEnvVar(context.Background(), tc.database, resourceID)
|
||||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
require.NoError(t, findErr)
|
||||||
|
assert.NotNil(t, found, "env var should still exist after IDOR attempt")
|
||||||
request := httptest.NewRequestWithContext(
|
},
|
||||||
t.Context(),
|
})
|
||||||
http.MethodPost,
|
|
||||||
"/apps/"+createdApp.ID+"/env",
|
|
||||||
strings.NewReader(body),
|
|
||||||
)
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
||||||
|
|
||||||
// Verify old env vars are gone and new ones exist
|
|
||||||
envVars, err := models.FindEnvVarsByAppID(
|
|
||||||
context.Background(), testCtx.database, createdApp.ID,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, envVars, 2)
|
|
||||||
|
|
||||||
keys := make(map[string]string)
|
|
||||||
for _, ev := range envVars {
|
|
||||||
keys[ev.Key] = ev.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, "new_value", keys["NEW_KEY"])
|
|
||||||
assert.Equal(t, "42", keys["ANOTHER"])
|
|
||||||
assert.Empty(t, keys["OLD_KEY"], "old env vars should be deleted")
|
|
||||||
assert.Empty(t, keys["REMOVE_ME"], "old env vars should be deleted")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleEnvVarSaveAppNotFound tests that HandleEnvVarSave returns 404
|
|
||||||
// for a non-existent app.
|
|
||||||
func TestHandleEnvVarSaveAppNotFound(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
|
||||||
|
|
||||||
body := `[{"key":"KEY","value":"value"}]`
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
|
||||||
"/apps/nonexistent-id/env",
|
|
||||||
strings.NewReader(body),
|
|
||||||
)
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleEnvVarSaveEmptyKeyRejected verifies that submitting a JSON
|
|
||||||
// array containing an entry with an empty key returns 400.
|
|
||||||
func TestHandleEnvVarSaveEmptyKeyRejected(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
|
||||||
createdApp := createTestApp(t, testCtx, "envvar-emptykey-app")
|
|
||||||
|
|
||||||
body := `[{"key":"VALID_KEY","value":"ok"},{"key":"","value":"bad"}]`
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
|
||||||
"/apps/"+createdApp.ID+"/env",
|
|
||||||
strings.NewReader(body),
|
|
||||||
)
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleEnvVarSaveDuplicateKeyRejected verifies that when the client
|
|
||||||
// sends duplicate keys, the server rejects them with 400 Bad Request.
|
|
||||||
func TestHandleEnvVarSaveDuplicateKeyRejected(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
|
||||||
createdApp := createTestApp(t, testCtx, "envvar-dedup-app")
|
|
||||||
|
|
||||||
// Send two entries with the same key — should be rejected
|
|
||||||
body := `[{"key":"FOO","value":"first"},{"key":"BAR","value":"bar"},` +
|
|
||||||
`{"key":"FOO","value":"second"}]`
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
|
||||||
"/apps/"+createdApp.ID+"/env",
|
|
||||||
strings.NewReader(body),
|
|
||||||
)
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
|
||||||
assert.Contains(t, recorder.Body.String(), "duplicate environment variable key: FOO")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleEnvVarSaveCrossAppIsolation verifies that posting env vars
|
|
||||||
// to appA's endpoint does not affect appB's env vars (IDOR prevention).
|
|
||||||
func TestHandleEnvVarSaveCrossAppIsolation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
|
||||||
appA := createTestApp(t, testCtx, "envvar-iso-appA")
|
|
||||||
appB := createTestApp(t, testCtx, "envvar-iso-appB")
|
|
||||||
|
|
||||||
// Give appB some env vars
|
|
||||||
for _, kv := range [][2]string{{"B_KEY1", "b_val1"}, {"B_KEY2", "b_val2"}} {
|
|
||||||
ev := models.NewEnvVar(testCtx.database)
|
|
||||||
ev.AppID = appB.ID
|
|
||||||
ev.Key = kv[0]
|
|
||||||
ev.Value = kv[1]
|
|
||||||
require.NoError(t, ev.Save(context.Background()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST new env vars to appA's endpoint
|
|
||||||
body := `[{"key":"A_KEY","value":"a_val"}]`
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
|
||||||
"/apps/"+appA.ID+"/env",
|
|
||||||
strings.NewReader(body),
|
|
||||||
)
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
||||||
|
|
||||||
// Verify appA has exactly what we sent
|
|
||||||
appAVars, err := models.FindEnvVarsByAppID(
|
|
||||||
context.Background(), testCtx.database, appA.ID,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, appAVars, 1)
|
|
||||||
assert.Equal(t, "A_KEY", appAVars[0].Key)
|
|
||||||
|
|
||||||
// Verify appB's env vars are completely untouched
|
|
||||||
appBVars, err := models.FindEnvVarsByAppID(
|
|
||||||
context.Background(), testCtx.database, appB.ID,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, appBVars, 2, "appB env vars must not be affected")
|
|
||||||
|
|
||||||
bKeys := make(map[string]string)
|
|
||||||
for _, ev := range appBVars {
|
|
||||||
bKeys[ev.Key] = ev.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, "b_val1", bKeys["B_KEY1"])
|
|
||||||
assert.Equal(t, "b_val2", bKeys["B_KEY2"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleEnvVarSaveBodySizeLimit verifies that a request body
|
|
||||||
// exceeding the 1 MB limit is rejected.
|
|
||||||
func TestHandleEnvVarSaveBodySizeLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
|
||||||
createdApp := createTestApp(t, testCtx, "envvar-sizelimit-app")
|
|
||||||
|
|
||||||
// Build a JSON body that exceeds 1 MB
|
|
||||||
// Each entry is ~30 bytes; 40000 entries ≈ 1.2 MB
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
sb.WriteString("[")
|
|
||||||
|
|
||||||
for i := range 40000 {
|
|
||||||
if i > 0 {
|
|
||||||
sb.WriteString(",")
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString(`{"key":"K` + strconv.Itoa(i) + `","value":"val"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString("]")
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
|
||||||
"/apps/"+createdApp.ID+"/env",
|
|
||||||
strings.NewReader(sb.String()),
|
|
||||||
)
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, recorder.Code,
|
|
||||||
"oversized body should be rejected with 400")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDeleteLabelOwnershipVerification tests that deleting a label
|
// TestDeleteLabelOwnershipVerification tests that deleting a label
|
||||||
// via another app's URL path returns 404 (IDOR prevention).
|
// via another app's URL path returns 404 (IDOR prevention).
|
||||||
func TestDeleteLabelOwnershipVerification(t *testing.T) {
|
func TestDeleteLabelOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
testOwnershipVerification(t, ownedResourceTestConfig{
|
testOwnershipVerification(t, ownedResourceTestConfig{
|
||||||
@@ -875,8 +632,7 @@ func TestDeleteVolumeOwnershipVerification(t *testing.T) {
|
|||||||
require.NoError(t, volume.Save(context.Background()))
|
require.NoError(t, volume.Save(context.Background()))
|
||||||
|
|
||||||
// Try to delete app1's volume using app2's URL path
|
// Try to delete app1's volume using app2's URL path
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/apps/"+app2.ID+"/volumes/"+strconv.FormatInt(volume.ID, 10)+"/delete",
|
"/apps/"+app2.ID+"/volumes/"+strconv.FormatInt(volume.ID, 10)+"/delete",
|
||||||
nil,
|
nil,
|
||||||
@@ -917,8 +673,7 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
|
|||||||
require.NoError(t, port.Save(context.Background()))
|
require.NoError(t, port.Save(context.Background()))
|
||||||
|
|
||||||
// Try to delete app1's port using app2's URL path
|
// Try to delete app1's port using app2's URL path
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/apps/"+app2.ID+"/ports/"+strconv.FormatInt(port.ID, 10)+"/delete",
|
"/apps/"+app2.ID+"/ports/"+strconv.FormatInt(port.ID, 10)+"/delete",
|
||||||
nil,
|
nil,
|
||||||
@@ -940,168 +695,6 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
|
|||||||
assert.NotNil(t, found, "port should still exist after IDOR attempt")
|
assert.NotNil(t, found, "port should still exist after IDOR attempt")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleEnvVarSaveEmptyClears verifies that submitting an empty JSON
|
|
||||||
// array deletes all existing env vars for the app.
|
|
||||||
func TestHandleEnvVarSaveEmptyClears(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
|
||||||
createdApp := createTestApp(t, testCtx, "envvar-clear-app")
|
|
||||||
|
|
||||||
// Create a pre-existing env var
|
|
||||||
ev := models.NewEnvVar(testCtx.database)
|
|
||||||
ev.AppID = createdApp.ID
|
|
||||||
ev.Key = "DELETE_ME"
|
|
||||||
ev.Value = "gone"
|
|
||||||
require.NoError(t, ev.Save(context.Background()))
|
|
||||||
|
|
||||||
// Submit empty JSON array
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
|
||||||
"/apps/"+createdApp.ID+"/env",
|
|
||||||
strings.NewReader("[]"),
|
|
||||||
)
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
||||||
|
|
||||||
// Verify all env vars are gone
|
|
||||||
envVars, err := models.FindEnvVarsByAppID(
|
|
||||||
context.Background(), testCtx.database, createdApp.ID,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, envVars, "all env vars should be deleted")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleVolumeAddValidatesPaths verifies that HandleVolumeAdd validates
|
|
||||||
// host and container paths (same as HandleVolumeEdit).
|
|
||||||
func TestHandleVolumeAddValidatesPaths(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
|
||||||
|
|
||||||
createdApp := createTestApp(t, testCtx, "volume-validate-app")
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
hostPath string
|
|
||||||
containerPath string
|
|
||||||
shouldCreate bool
|
|
||||||
}{
|
|
||||||
{"relative host path rejected", "relative/path", "/container", false},
|
|
||||||
{"relative container path rejected", "/host", "relative/path", false},
|
|
||||||
{"unclean host path rejected", "/host/../etc", "/container", false},
|
|
||||||
{"valid paths accepted", "/host/data", "/container/data", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("host_path", tt.hostPath)
|
|
||||||
form.Set("container_path", tt.containerPath)
|
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
|
||||||
"/apps/"+createdApp.ID+"/volumes",
|
|
||||||
strings.NewReader(form.Encode()),
|
|
||||||
)
|
|
||||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
|
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := testCtx.handlers.HandleVolumeAdd()
|
|
||||||
handler.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusSeeOther, recorder.Code)
|
|
||||||
|
|
||||||
// Check if volume was created by listing volumes
|
|
||||||
volumes, _ := createdApp.GetVolumes(context.Background())
|
|
||||||
found := false
|
|
||||||
|
|
||||||
for _, v := range volumes {
|
|
||||||
if v.HostPath == tt.hostPath && v.ContainerPath == tt.containerPath {
|
|
||||||
found = true
|
|
||||||
// Clean up for isolation
|
|
||||||
_ = v.Delete(context.Background())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.shouldCreate {
|
|
||||||
assert.True(t, found, "volume should be created for valid paths")
|
|
||||||
} else {
|
|
||||||
assert.False(t, found, "volume should NOT be created for invalid paths")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSetupRequiredExemptsHealthAndStaticAndAPI verifies that the SetupRequired
|
|
||||||
// middleware allows /health, /s/*, and /api/* paths through even when setup is
|
|
||||||
// required.
|
|
||||||
func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
|
||||||
|
|
||||||
// No user created, so setup IS required
|
|
||||||
mw := testCtx.middleware.SetupRequired()
|
|
||||||
|
|
||||||
okHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("OK"))
|
|
||||||
})
|
|
||||||
|
|
||||||
wrapped := mw(okHandler)
|
|
||||||
|
|
||||||
exemptPaths := []string{
|
|
||||||
"/health",
|
|
||||||
"/s/style.css",
|
|
||||||
"/s/js/app.js",
|
|
||||||
"/api/v1/apps",
|
|
||||||
"/api/v1/login",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, path := range exemptPaths {
|
|
||||||
t.Run(path, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, path, nil,
|
|
||||||
)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
wrapped.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rr.Code,
|
|
||||||
"path %s should be exempt from setup redirect", path)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-exempt path should redirect to /setup
|
|
||||||
t.Run("non-exempt redirects", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, "/", nil,
|
|
||||||
)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
wrapped.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusSeeOther, rr.Code)
|
|
||||||
assert.Equal(t, "/setup", rr.Header().Get("Location"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCancelDeployRedirects(t *testing.T) {
|
func TestHandleCancelDeployRedirects(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1109,8 +702,7 @@ func TestHandleCancelDeployRedirects(t *testing.T) {
|
|||||||
|
|
||||||
createdApp := createTestApp(t, testCtx, "cancel-deploy-app")
|
createdApp := createTestApp(t, testCtx, "cancel-deploy-app")
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/apps/"+createdApp.ID+"/deployments/cancel",
|
"/apps/"+createdApp.ID+"/deployments/cancel",
|
||||||
nil,
|
nil,
|
||||||
@@ -1130,8 +722,7 @@ func TestHandleCancelDeployReturns404ForUnknownApp(t *testing.T) {
|
|||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
testCtx := setupTestHandlers(t)
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/apps/nonexistent/deployments/cancel",
|
"/apps/nonexistent/deployments/cancel",
|
||||||
nil,
|
nil,
|
||||||
@@ -1152,16 +743,12 @@ func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
|
|||||||
|
|
||||||
webhookURL := "/webhook/unknown-secret"
|
webhookURL := "/webhook/unknown-secret"
|
||||||
payload := `{"ref": "refs/heads/main"}`
|
payload := `{"ref": "refs/heads/main"}`
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
webhookURL,
|
webhookURL,
|
||||||
strings.NewReader(payload),
|
strings.NewReader(payload),
|
||||||
)
|
)
|
||||||
request = addChiURLParams(
|
request = addChiURLParams(request, map[string]string{"secret": "unknown-secret"})
|
||||||
request,
|
|
||||||
map[string]string{paramSecret: "unknown-secret"},
|
|
||||||
)
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
request.Header.Set("X-Gitea-Event", "push")
|
request.Header.Set("X-Gitea-Event", "push")
|
||||||
|
|
||||||
@@ -1184,22 +771,21 @@ func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
|
|||||||
app.CreateAppInput{
|
app.CreateAppInput{
|
||||||
Name: "webhook-test-app",
|
Name: "webhook-test-app",
|
||||||
RepoURL: "git@example.com:user/repo.git",
|
RepoURL: "git@example.com:user/repo.git",
|
||||||
Branch: branchMain,
|
Branch: "main",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
require.NoError(t, createErr)
|
require.NoError(t, createErr)
|
||||||
|
|
||||||
payload := `{"ref": "refs/heads/main", "after": "abc123"}`
|
payload := `{"ref": "refs/heads/main", "after": "abc123"}`
|
||||||
webhookURL := "/webhook/" + createdApp.WebhookSecret
|
webhookURL := "/webhook/" + createdApp.WebhookSecret
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(
|
||||||
t.Context(),
|
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
webhookURL,
|
webhookURL,
|
||||||
strings.NewReader(payload),
|
strings.NewReader(payload),
|
||||||
)
|
)
|
||||||
request = addChiURLParams(
|
request = addChiURLParams(
|
||||||
request,
|
request,
|
||||||
map[string]string{paramSecret: createdApp.WebhookSecret},
|
map[string]string{"secret": createdApp.WebhookSecret},
|
||||||
)
|
)
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
request.Header.Set("X-Gitea-Event", "push")
|
request.Header.Set("X-Gitea-Event", "push")
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ func TestRenderTemplateBuffersOutput(t *testing.T) {
|
|||||||
testCtx := setupTestHandlers(t)
|
testCtx := setupTestHandlers(t)
|
||||||
|
|
||||||
// The setup page is simple and has no DB dependencies
|
// The setup page is simple and has no DB dependencies
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(http.MethodGet, "/setup", nil)
|
||||||
t.Context(), http.MethodGet, "/setup", nil,
|
|
||||||
)
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
handler := testCtx.handlers.HandleSetupGET()
|
handler := testCtx.handlers.HandleSetupGET()
|
||||||
@@ -41,7 +39,7 @@ func TestDashboardRenderTemplateBuffersOutput(t *testing.T) {
|
|||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
testCtx := setupTestHandlers(t)
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
handler := testCtx.handlers.HandleDashboard()
|
handler := testCtx.handlers.HandleDashboard()
|
||||||
@@ -61,9 +59,7 @@ func TestLoginRenderTemplateBuffersOutput(t *testing.T) {
|
|||||||
|
|
||||||
testCtx := setupTestHandlers(t)
|
testCtx := setupTestHandlers(t)
|
||||||
|
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||||
t.Context(), http.MethodGet, "/login", nil,
|
|
||||||
)
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
handler := testCtx.handlers.HandleLoginGET()
|
handler := testCtx.handlers.HandleLoginGET()
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/templates"
|
"git.eeqj.de/sneak/upaas/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -56,7 +56,7 @@ func (h *Handlers) renderSetupError(
|
|||||||
) {
|
) {
|
||||||
data := h.addGlobals(map[string]any{
|
data := h.addGlobals(map[string]any{
|
||||||
"Username": username,
|
"Username": username,
|
||||||
dataKeyError: errorMsg,
|
"Error": errorMsg,
|
||||||
}, request)
|
}, request)
|
||||||
h.renderTemplate(writer, tmpl, "setup.html", data)
|
h.renderTemplate(writer, tmpl, "setup.html", data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package handlers_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/handlers"
|
"git.eeqj.de/sneak/upaas/internal/handlers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSanitizeTail(t *testing.T) {
|
func TestSanitizeTail(t *testing.T) {
|
||||||
|
|||||||
@@ -6,15 +6,13 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/models"
|
"git.eeqj.de/sneak/upaas/internal/models"
|
||||||
"sneak.berlin/go/upaas/internal/service/webhook"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB).
|
// maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB).
|
||||||
const maxWebhookBodySize = 1 << 20
|
const maxWebhookBodySize = 1 << 20
|
||||||
|
|
||||||
// HandleWebhook handles incoming webhooks from Gitea, GitHub, or GitLab.
|
// HandleWebhook handles incoming Gitea webhooks.
|
||||||
// The webhook source is auto-detected from HTTP headers.
|
|
||||||
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
secret := chi.URLParam(request, "secret")
|
secret := chi.URLParam(request, "secret")
|
||||||
@@ -52,17 +50,16 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-detect webhook source from headers
|
// Get event type from header
|
||||||
source := webhook.DetectWebhookSource(request.Header)
|
eventType := request.Header.Get("X-Gitea-Event")
|
||||||
|
if eventType == "" {
|
||||||
// Extract event type based on detected source
|
eventType = "push"
|
||||||
eventType := webhook.DetectEventType(request.Header, source)
|
}
|
||||||
|
|
||||||
// Process webhook
|
// Process webhook
|
||||||
webhookErr := h.webhook.HandleWebhook(
|
webhookErr := h.webhook.HandleWebhook(
|
||||||
request.Context(),
|
request.Context(),
|
||||||
application,
|
application,
|
||||||
source,
|
|
||||||
eventType,
|
eventType,
|
||||||
body,
|
body,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
"sneak.berlin/go/upaas/internal/globals"
|
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params contains dependencies for Healthcheck.
|
// Params contains dependencies for Healthcheck.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/globals"
|
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params contains dependencies for Logger.
|
// Params contains dependencies for Logger.
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package middleware //nolint:testpackage // tests internal CORS behavior
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
//nolint:gosec // test credentials
|
|
||||||
func newCORSTestMiddleware(corsOrigins string) *Middleware {
|
|
||||||
return &Middleware{
|
|
||||||
log: slog.Default(),
|
|
||||||
params: &Params{
|
|
||||||
Config: &config.Config{
|
|
||||||
CORSOrigins: corsOrigins,
|
|
||||||
SessionSecret: "test-secret-32-bytes-long-enough",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertNoCORSHeaders runs a request with the given Origin header through
|
|
||||||
// CORS middleware configured with corsOrigins and asserts that no
|
|
||||||
// Access-Control-Allow-Origin header is set.
|
|
||||||
func assertNoCORSHeaders(t *testing.T, corsOrigins, origin, msg string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
m := newCORSTestMiddleware(corsOrigins)
|
|
||||||
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
|
||||||
req.Header.Set("Origin", origin)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"), msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCORS_NoOriginsConfigured_NoCORSHeaders(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assertNoCORSHeaders(t, "", "https://evil.com",
|
|
||||||
"expected no CORS headers when no origins configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCORS_OriginsConfigured_AllowsMatchingOrigin(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newCORSTestMiddleware("https://app.example.com,https://other.example.com")
|
|
||||||
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
|
||||||
req.Header.Set("Origin", "https://app.example.com")
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
assert.Equal(t, "https://app.example.com",
|
|
||||||
rec.Header().Get("Access-Control-Allow-Origin"))
|
|
||||||
assert.Equal(t, "true",
|
|
||||||
rec.Header().Get("Access-Control-Allow-Credentials"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCORS_OriginsConfigured_RejectsNonMatchingOrigin(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assertNoCORSHeaders(t, "https://app.example.com", "https://evil.com",
|
|
||||||
"expected no CORS headers for non-matching origin")
|
|
||||||
}
|
|
||||||
@@ -18,10 +18,10 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
"sneak.berlin/go/upaas/internal/globals"
|
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/service/auth"
|
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// corsMaxAge is the maximum age for CORS preflight responses in seconds.
|
// corsMaxAge is the maximum age for CORS preflight responses in seconds.
|
||||||
@@ -177,48 +177,17 @@ func realIP(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CORS returns CORS middleware.
|
// CORS returns CORS middleware.
|
||||||
// When UPAAS_CORS_ORIGINS is empty (default), no CORS headers are sent
|
|
||||||
// (same-origin only). When configured, only the specified origins are
|
|
||||||
// allowed and credentials (cookies) are permitted.
|
|
||||||
func (m *Middleware) CORS() func(http.Handler) http.Handler {
|
func (m *Middleware) CORS() func(http.Handler) http.Handler {
|
||||||
origins := parseCORSOrigins(m.params.Config.CORSOrigins)
|
|
||||||
|
|
||||||
// No origins configured — no CORS headers (same-origin policy).
|
|
||||||
if len(origins) == 0 {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cors.Handler(cors.Options{
|
return cors.Handler(cors.Options{
|
||||||
AllowedOrigins: origins,
|
AllowedOrigins: []string{"*"},
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||||
ExposedHeaders: []string{"Link"},
|
ExposedHeaders: []string{"Link"},
|
||||||
AllowCredentials: true,
|
AllowCredentials: false,
|
||||||
MaxAge: corsMaxAge,
|
MaxAge: corsMaxAge,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCORSOrigins splits a comma-separated origin string into a slice,
|
|
||||||
// trimming whitespace. Returns nil if the input is empty.
|
|
||||||
func parseCORSOrigins(raw string) []string {
|
|
||||||
if raw == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(raw, ",")
|
|
||||||
origins := make([]string, 0, len(parts))
|
|
||||||
|
|
||||||
for _, p := range parts {
|
|
||||||
if o := strings.TrimSpace(p); o != "" {
|
|
||||||
origins = append(origins, o)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return origins
|
|
||||||
}
|
|
||||||
|
|
||||||
// MetricsAuth returns basic auth middleware for metrics endpoint.
|
// MetricsAuth returns basic auth middleware for metrics endpoint.
|
||||||
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||||
if m.params.Config.MetricsUsername == "" {
|
if m.params.Config.MetricsUsername == "" {
|
||||||
@@ -370,9 +339,8 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// APISessionAuth returns middleware that requires session authentication
|
// APISessionAuth returns middleware that requires session authentication for API routes.
|
||||||
// for API routes. Unlike SessionAuth, it returns JSON 401 responses instead
|
// Unlike SessionAuth, it returns JSON 401 responses instead of redirecting to /login.
|
||||||
// of redirecting to /login.
|
|
||||||
func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler {
|
func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(
|
return http.HandlerFunc(func(
|
||||||
@@ -412,14 +380,8 @@ func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if setupRequired {
|
if setupRequired {
|
||||||
path := request.URL.Path
|
// Allow access to setup page
|
||||||
|
if request.URL.Path == "/setup" {
|
||||||
// Allow access to setup page, health endpoint, static
|
|
||||||
// assets, and API routes even before setup is complete.
|
|
||||||
if path == "/setup" ||
|
|
||||||
path == "/health" ||
|
|
||||||
strings.HasPrefix(path, "/s/") ||
|
|
||||||
strings.HasPrefix(path, "/api/") {
|
|
||||||
next.ServeHTTP(writer, request)
|
next.ServeHTTP(writer, request)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestMiddleware(t *testing.T) *Middleware {
|
func newTestMiddleware(t *testing.T) *Middleware {
|
||||||
@@ -30,15 +30,13 @@ func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
|
|||||||
|
|
||||||
mw := newTestMiddleware(t)
|
mw := newTestMiddleware(t)
|
||||||
|
|
||||||
handler := mw.LoginRateLimit()(http.HandlerFunc(
|
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
},
|
}))
|
||||||
))
|
|
||||||
|
|
||||||
// First 5 requests should succeed (burst)
|
// First 5 requests should succeed (burst)
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
|
req := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||||
req.RemoteAddr = "192.168.1.1:12345"
|
req.RemoteAddr = "192.168.1.1:12345"
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
@@ -46,12 +44,11 @@ func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 6th request should be rate limited
|
// 6th request should be rate limited
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
|
req := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||||
req.RemoteAddr = "192.168.1.1:12345"
|
req.RemoteAddr = "192.168.1.1:12345"
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
assert.Equal(t, http.StatusTooManyRequests, rec.Code,
|
assert.Equal(t, http.StatusTooManyRequests, rec.Code, "6th request should be rate limited")
|
||||||
"6th request should be rate limited")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // mutates global loginLimiter
|
//nolint:paralleltest // mutates global loginLimiter
|
||||||
@@ -60,29 +57,27 @@ func TestLoginRateLimitIsolatesIPs(t *testing.T) {
|
|||||||
|
|
||||||
mw := newTestMiddleware(t)
|
mw := newTestMiddleware(t)
|
||||||
|
|
||||||
handler := mw.LoginRateLimit()(http.HandlerFunc(
|
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
},
|
}))
|
||||||
))
|
|
||||||
|
|
||||||
// Exhaust IP1's budget
|
// Exhaust IP1's budget
|
||||||
for range 5 {
|
for range 5 {
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
|
req := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||||
req.RemoteAddr = testProxyAddr
|
req.RemoteAddr = "10.0.0.1:1234"
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IP1 should be blocked
|
// IP1 should be blocked
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
|
req := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||||
req.RemoteAddr = testProxyAddr
|
req.RemoteAddr = "10.0.0.1:1234"
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
|
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
|
||||||
|
|
||||||
// IP2 should still work
|
// IP2 should still work
|
||||||
req2 := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
|
req2 := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||||
req2.RemoteAddr = "10.0.0.2:1234"
|
req2.RemoteAddr = "10.0.0.2:1234"
|
||||||
rec2 := httptest.NewRecorder()
|
rec2 := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rec2, req2)
|
handler.ServeHTTP(rec2, req2)
|
||||||
@@ -95,28 +90,25 @@ func TestLoginRateLimitReturns429Body(t *testing.T) {
|
|||||||
|
|
||||||
mw := newTestMiddleware(t)
|
mw := newTestMiddleware(t)
|
||||||
|
|
||||||
handler := mw.LoginRateLimit()(http.HandlerFunc(
|
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
},
|
}))
|
||||||
))
|
|
||||||
|
|
||||||
// Exhaust burst
|
// Exhaust burst
|
||||||
for range 5 {
|
for range 5 {
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
|
req := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||||
req.RemoteAddr = "172.16.0.1:5555"
|
req.RemoteAddr = "172.16.0.1:5555"
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
|
req := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||||
req.RemoteAddr = "172.16.0.1:5555"
|
req.RemoteAddr = "172.16.0.1:5555"
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
|
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
|
||||||
assert.Contains(t, rec.Body.String(), "Too Many Requests")
|
assert.Contains(t, rec.Body.String(), "Too Many Requests")
|
||||||
assert.NotEmpty(t, rec.Header().Get("Retry-After"),
|
assert.NotEmpty(t, rec.Header().Get("Retry-After"), "should include Retry-After header")
|
||||||
"should include Retry-After header")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIPLimiterEvictsStaleEntries(t *testing.T) {
|
func TestIPLimiterEvictsStaleEntries(t *testing.T) {
|
||||||
|
|||||||
@@ -7,16 +7,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shared test addresses (also used by ratelimit_test.go).
|
|
||||||
const (
|
|
||||||
testProxyAddr = "10.0.0.1:1234"
|
|
||||||
testRealIP = "203.0.113.5"
|
|
||||||
testXFFIP = "198.51.100.1"
|
|
||||||
testPrivateIP = "192.168.1.1"
|
|
||||||
testPublicIP = "93.184.216.34"
|
|
||||||
testPublicDNSIP = "8.8.8.8"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
|
func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -30,63 +20,63 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
|
|||||||
// === Trusted proxy (RFC1918 / loopback) — headers ARE honoured ===
|
// === Trusted proxy (RFC1918 / loopback) — headers ARE honoured ===
|
||||||
{
|
{
|
||||||
name: "trusted: X-Real-IP from 10.x",
|
name: "trusted: X-Real-IP from 10.x",
|
||||||
remoteAddr: testProxyAddr,
|
remoteAddr: "10.0.0.1:1234",
|
||||||
xRealIP: testRealIP,
|
xRealIP: "203.0.113.5",
|
||||||
xff: "198.51.100.1, 10.0.0.1",
|
xff: "198.51.100.1, 10.0.0.1",
|
||||||
want: testRealIP,
|
want: "203.0.113.5",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trusted: XFF from 10.x when no X-Real-IP",
|
name: "trusted: XFF from 10.x when no X-Real-IP",
|
||||||
remoteAddr: testProxyAddr,
|
remoteAddr: "10.0.0.1:1234",
|
||||||
xff: "198.51.100.1, 10.0.0.1",
|
xff: "198.51.100.1, 10.0.0.1",
|
||||||
want: testXFFIP,
|
want: "198.51.100.1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trusted: XFF single IP from 10.x",
|
name: "trusted: XFF single IP from 10.x",
|
||||||
remoteAddr: testProxyAddr,
|
remoteAddr: "10.0.0.1:1234",
|
||||||
xff: "203.0.113.10",
|
xff: "203.0.113.10",
|
||||||
want: "203.0.113.10",
|
want: "203.0.113.10",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trusted: falls back to RemoteAddr (192.168.x)",
|
name: "trusted: falls back to RemoteAddr (192.168.x)",
|
||||||
remoteAddr: "192.168.1.1:5678",
|
remoteAddr: "192.168.1.1:5678",
|
||||||
want: testPrivateIP,
|
want: "192.168.1.1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trusted: RemoteAddr without port",
|
name: "trusted: RemoteAddr without port",
|
||||||
remoteAddr: testPrivateIP,
|
remoteAddr: "192.168.1.1",
|
||||||
want: testPrivateIP,
|
want: "192.168.1.1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trusted: X-Real-IP with whitespace from 10.x",
|
name: "trusted: X-Real-IP with whitespace from 10.x",
|
||||||
remoteAddr: testProxyAddr,
|
remoteAddr: "10.0.0.1:1234",
|
||||||
xRealIP: " 203.0.113.5 ",
|
xRealIP: " 203.0.113.5 ",
|
||||||
want: testRealIP,
|
want: "203.0.113.5",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trusted: XFF with whitespace from 10.x",
|
name: "trusted: XFF with whitespace from 10.x",
|
||||||
remoteAddr: testProxyAddr,
|
remoteAddr: "10.0.0.1:1234",
|
||||||
xff: " 198.51.100.1 , 10.0.0.1",
|
xff: " 198.51.100.1 , 10.0.0.1",
|
||||||
want: testXFFIP,
|
want: "198.51.100.1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trusted: empty X-Real-IP falls through to XFF from 10.x",
|
name: "trusted: empty X-Real-IP falls through to XFF from 10.x",
|
||||||
remoteAddr: testProxyAddr,
|
remoteAddr: "10.0.0.1:1234",
|
||||||
xRealIP: " ",
|
xRealIP: " ",
|
||||||
xff: testXFFIP,
|
xff: "198.51.100.1",
|
||||||
want: testXFFIP,
|
want: "198.51.100.1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trusted: loopback honours X-Real-IP",
|
name: "trusted: loopback honours X-Real-IP",
|
||||||
remoteAddr: "127.0.0.1:9999",
|
remoteAddr: "127.0.0.1:9999",
|
||||||
xRealIP: testPublicIP,
|
xRealIP: "93.184.216.34",
|
||||||
want: testPublicIP,
|
want: "93.184.216.34",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trusted: 172.16.x honours XFF",
|
name: "trusted: 172.16.x honours XFF",
|
||||||
remoteAddr: "172.16.0.1:4321",
|
remoteAddr: "172.16.0.1:4321",
|
||||||
xff: testPublicDNSIP,
|
xff: "8.8.8.8",
|
||||||
want: testPublicDNSIP,
|
want: "8.8.8.8",
|
||||||
},
|
},
|
||||||
|
|
||||||
// === Untrusted proxy (public IP) — headers IGNORED, use RemoteAddr ===
|
// === Untrusted proxy (public IP) — headers IGNORED, use RemoteAddr ===
|
||||||
@@ -107,17 +97,17 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
|
|||||||
remoteAddr: "8.8.8.8:443",
|
remoteAddr: "8.8.8.8:443",
|
||||||
xRealIP: "1.2.3.4",
|
xRealIP: "1.2.3.4",
|
||||||
xff: "5.6.7.8",
|
xff: "5.6.7.8",
|
||||||
want: testPublicDNSIP,
|
want: "8.8.8.8",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "untrusted: no headers, public RemoteAddr",
|
name: "untrusted: no headers, public RemoteAddr",
|
||||||
remoteAddr: "93.184.216.34:8080",
|
remoteAddr: "93.184.216.34:8080",
|
||||||
want: testPublicIP,
|
want: "93.184.216.34",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "untrusted: public RemoteAddr without port",
|
name: "untrusted: public RemoteAddr without port",
|
||||||
remoteAddr: testPublicIP,
|
remoteAddr: "93.184.216.34",
|
||||||
want: testPublicIP,
|
want: "93.184.216.34",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,9 +139,7 @@ func TestIsTrustedProxy(t *testing.T) {
|
|||||||
|
|
||||||
trusted := []string{"10.0.0.1", "10.255.255.255", "172.16.0.1", "172.31.255.255",
|
trusted := []string{"10.0.0.1", "10.255.255.255", "172.16.0.1", "172.31.255.255",
|
||||||
"192.168.0.1", "192.168.255.255", "127.0.0.1", "127.255.255.255", "::1"}
|
"192.168.0.1", "192.168.255.255", "127.0.0.1", "127.255.255.255", "::1"}
|
||||||
untrusted := []string{
|
untrusted := []string{"8.8.8.8", "203.0.113.1", "172.32.0.1", "11.0.0.1", "2001:db8::1"}
|
||||||
testPublicDNSIP, "203.0.113.1", "172.32.0.1", "11.0.0.1", "2001:db8::1",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, addr := range trusted {
|
for _, addr := range trusted {
|
||||||
ip := net.ParseIP(addr)
|
ip := net.ParseIP(addr)
|
||||||
|
|||||||
@@ -7,15 +7,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// appColumns is the standard column list for app queries.
|
// appColumns is the standard column list for app queries.
|
||||||
const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||||
ssh_private_key, ssh_public_key, image_id, status,
|
ssh_private_key, ssh_public_key, image_id, status,
|
||||||
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
|
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
|
||||||
previous_image_id, cpu_limit, memory_limit,
|
previous_image_id, created_at, updated_at`
|
||||||
created_at, updated_at`
|
|
||||||
|
|
||||||
// AppStatus represents the status of an app.
|
// AppStatus represents the status of an app.
|
||||||
type AppStatus string
|
type AppStatus string
|
||||||
@@ -48,8 +47,6 @@ type App struct {
|
|||||||
DockerNetwork sql.NullString
|
DockerNetwork sql.NullString
|
||||||
NtfyTopic sql.NullString
|
NtfyTopic sql.NullString
|
||||||
SlackWebhook sql.NullString
|
SlackWebhook sql.NullString
|
||||||
CPULimit sql.NullFloat64
|
|
||||||
MemoryLimit sql.NullInt64
|
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -145,14 +142,14 @@ func (a *App) insert(ctx context.Context) error {
|
|||||||
id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||||
ssh_private_key, ssh_public_key, image_id, status,
|
ssh_private_key, ssh_public_key, image_id, status,
|
||||||
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
|
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
|
||||||
previous_image_id, cpu_limit, memory_limit
|
previous_image_id
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
|
||||||
_, err := a.db.Exec(ctx, query,
|
_, err := a.db.Exec(ctx, query,
|
||||||
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
|
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
|
||||||
a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status,
|
a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status,
|
||||||
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash,
|
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash,
|
||||||
a.PreviousImageID, a.CPULimit, a.MemoryLimit,
|
a.PreviousImageID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -168,7 +165,6 @@ func (a *App) update(ctx context.Context) error {
|
|||||||
image_id = ?, status = ?,
|
image_id = ?, status = ?,
|
||||||
docker_network = ?, ntfy_topic = ?, slack_webhook = ?,
|
docker_network = ?, ntfy_topic = ?, slack_webhook = ?,
|
||||||
previous_image_id = ?,
|
previous_image_id = ?,
|
||||||
cpu_limit = ?, memory_limit = ?,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?`
|
WHERE id = ?`
|
||||||
|
|
||||||
@@ -177,7 +173,6 @@ func (a *App) update(ctx context.Context) error {
|
|||||||
a.ImageID, a.Status,
|
a.ImageID, a.Status,
|
||||||
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
|
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
|
||||||
a.PreviousImageID,
|
a.PreviousImageID,
|
||||||
a.CPULimit, a.MemoryLimit,
|
|
||||||
a.ID,
|
a.ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -193,7 +188,6 @@ func (a *App) scan(row *sql.Row) error {
|
|||||||
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
|
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
|
||||||
&a.WebhookSecretHash,
|
&a.WebhookSecretHash,
|
||||||
&a.PreviousImageID,
|
&a.PreviousImageID,
|
||||||
&a.CPULimit, &a.MemoryLimit,
|
|
||||||
&a.CreatedAt, &a.UpdatedAt,
|
&a.CreatedAt, &a.UpdatedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -212,7 +206,6 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
|
|||||||
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
|
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
|
||||||
&app.WebhookSecretHash,
|
&app.WebhookSecretHash,
|
||||||
&app.PreviousImageID,
|
&app.PreviousImageID,
|
||||||
&app.CPULimit, &app.MemoryLimit,
|
|
||||||
&app.CreatedAt, &app.UpdatedAt,
|
&app.CreatedAt, &app.UpdatedAt,
|
||||||
)
|
)
|
||||||
if scanErr != nil {
|
if scanErr != nil {
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeploymentStatus represents the status of a deployment.
|
// DeploymentStatus represents the status of a deployment.
|
||||||
@@ -77,11 +76,7 @@ func (d *Deployment) Reload(ctx context.Context) error {
|
|||||||
return d.scan(row)
|
return d.scan(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxLogSize is the maximum size of deployment logs stored in the database (1MB).
|
|
||||||
const maxLogSize = 1 << 20
|
|
||||||
|
|
||||||
// AppendLog appends a log line to the deployment logs.
|
// AppendLog appends a log line to the deployment logs.
|
||||||
// If the total log size exceeds maxLogSize, the oldest lines are truncated.
|
|
||||||
func (d *Deployment) AppendLog(ctx context.Context, line string) error {
|
func (d *Deployment) AppendLog(ctx context.Context, line string) error {
|
||||||
var currentLogs string
|
var currentLogs string
|
||||||
|
|
||||||
@@ -89,22 +84,7 @@ func (d *Deployment) AppendLog(ctx context.Context, line string) error {
|
|||||||
currentLogs = d.Logs.String
|
currentLogs = d.Logs.String
|
||||||
}
|
}
|
||||||
|
|
||||||
newLogs := currentLogs + line + "\n"
|
d.Logs = sql.NullString{String: currentLogs + line + "\n", Valid: true}
|
||||||
|
|
||||||
if len(newLogs) > maxLogSize {
|
|
||||||
// Keep the most recent logs that fit within the limit.
|
|
||||||
// Find a newline after the truncation point to avoid partial lines.
|
|
||||||
truncateAt := len(newLogs) - maxLogSize
|
|
||||||
idx := strings.Index(newLogs[truncateAt:], "\n")
|
|
||||||
|
|
||||||
if idx >= 0 {
|
|
||||||
newLogs = "[earlier logs truncated]\n" + newLogs[truncateAt+idx+1:]
|
|
||||||
} else {
|
|
||||||
newLogs = "[earlier logs truncated]\n" + newLogs[truncateAt:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
d.Logs = sql.NullString{String: newLogs, Valid: true}
|
|
||||||
|
|
||||||
return d.Save(ctx)
|
return d.Save(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
//nolint:dupl // Active Record pattern - similar structure to label.go is intentional
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -6,7 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EnvVar represents an environment variable for an app.
|
// EnvVar represents an environment variable for an app.
|
||||||
@@ -93,41 +94,6 @@ func FindEnvVar(
|
|||||||
return envVar, nil
|
return envVar, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// findAllByAppID loads all rows for an app, scanning each row into a
|
|
||||||
// new model created by newFn. entity names the model in error messages.
|
|
||||||
func findAllByAppID[T interface{ scanDest() []any }](
|
|
||||||
ctx context.Context,
|
|
||||||
db *database.Database,
|
|
||||||
query, appID, entity string,
|
|
||||||
newFn func(*database.Database) T,
|
|
||||||
) ([]T, error) {
|
|
||||||
rows, err := db.Query(ctx, query, appID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("querying %s by app: %w", entity, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var items []T
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
item := newFn(db)
|
|
||||||
|
|
||||||
scanErr := rows.Scan(item.scanDest()...)
|
|
||||||
if scanErr != nil {
|
|
||||||
return nil, scanErr
|
|
||||||
}
|
|
||||||
|
|
||||||
items = append(items, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
return items, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EnvVar) scanDest() []any {
|
|
||||||
return []any{&e.ID, &e.AppID, &e.Key, &e.Value}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindEnvVarsByAppID finds all env vars for an app.
|
// FindEnvVarsByAppID finds all env vars for an app.
|
||||||
func FindEnvVarsByAppID(
|
func FindEnvVarsByAppID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -138,51 +104,38 @@ func FindEnvVarsByAppID(
|
|||||||
SELECT id, app_id, key, value FROM app_env_vars
|
SELECT id, app_id, key, value FROM app_env_vars
|
||||||
WHERE app_id = ? ORDER BY key`
|
WHERE app_id = ? ORDER BY key`
|
||||||
|
|
||||||
return findAllByAppID(ctx, db, query, appID, "env vars", NewEnvVar)
|
rows, err := db.Query(ctx, query, appID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying env vars by app: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var envVars []*EnvVar
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
envVar := NewEnvVar(db)
|
||||||
|
|
||||||
|
scanErr := rows.Scan(
|
||||||
|
&envVar.ID, &envVar.AppID, &envVar.Key, &envVar.Value,
|
||||||
|
)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
|
||||||
|
envVars = append(envVars, envVar)
|
||||||
|
}
|
||||||
|
|
||||||
|
return envVars, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnvVarPair is a key-value pair for bulk env var operations.
|
// DeleteEnvVarsByAppID deletes all env vars for an app.
|
||||||
type EnvVarPair struct {
|
func DeleteEnvVarsByAppID(
|
||||||
Key string
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReplaceEnvVarsByAppID atomically replaces all env vars for an app
|
|
||||||
// within a single database transaction. It deletes all existing env
|
|
||||||
// vars and inserts the provided pairs. If any operation fails, the
|
|
||||||
// entire transaction is rolled back.
|
|
||||||
func ReplaceEnvVarsByAppID(
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
db *database.Database,
|
db *database.Database,
|
||||||
appID string,
|
appID string,
|
||||||
pairs []EnvVarPair,
|
|
||||||
) error {
|
) error {
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
_, err := db.Exec(ctx, "DELETE FROM app_env_vars WHERE app_id = ?", appID)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("beginning transaction: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = tx.Rollback() }()
|
return err
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, "DELETE FROM app_env_vars WHERE app_id = ?", appID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("deleting env vars: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range pairs {
|
|
||||||
_, err = tx.ExecContext(ctx,
|
|
||||||
"INSERT INTO app_env_vars (app_id, key, value) VALUES (?, ?, ?)",
|
|
||||||
appID, p.Key, p.Value,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("inserting env var %q: %w", p.Key, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.Commit()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("committing transaction: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
//nolint:dupl // Active Record pattern - similar structure to env_var.go is intentional
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -6,7 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Label represents a Docker label for an app container.
|
// Label represents a Docker label for an app container.
|
||||||
@@ -93,10 +94,6 @@ func FindLabel(
|
|||||||
return label, nil
|
return label, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Label) scanDest() []any {
|
|
||||||
return []any{&l.ID, &l.AppID, &l.Key, &l.Value}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindLabelsByAppID finds all labels for an app.
|
// FindLabelsByAppID finds all labels for an app.
|
||||||
func FindLabelsByAppID(
|
func FindLabelsByAppID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -107,7 +104,27 @@ func FindLabelsByAppID(
|
|||||||
SELECT id, app_id, key, value FROM app_labels
|
SELECT id, app_id, key, value FROM app_labels
|
||||||
WHERE app_id = ? ORDER BY key`
|
WHERE app_id = ? ORDER BY key`
|
||||||
|
|
||||||
return findAllByAppID(ctx, db, query, appID, "labels", NewLabel)
|
rows, err := db.Query(ctx, query, appID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying labels by app: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var labels []*Label
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
label := NewLabel(db)
|
||||||
|
|
||||||
|
scanErr := rows.Scan(&label.ID, &label.AppID, &label.Key, &label.Value)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
|
||||||
|
labels = append(labels, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteLabelsByAppID deletes all labels for an app.
|
// DeleteLabelsByAppID deletes all labels for an app.
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
"sneak.berlin/go/upaas/internal/globals"
|
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/models"
|
"git.eeqj.de/sneak/upaas/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Test constants to satisfy goconst linter.
|
// Test constants to satisfy goconst linter.
|
||||||
@@ -317,16 +317,11 @@ func TestAllApps(t *testing.T) {
|
|||||||
|
|
||||||
// EnvVar Tests.
|
// EnvVar Tests.
|
||||||
|
|
||||||
// testKVCreateAndFind exercises the create-and-find round trip shared
|
func TestEnvVarCRUD(t *testing.T) {
|
||||||
// by key-value models (env vars, labels).
|
t.Parallel()
|
||||||
func testKVCreateAndFind[T any](
|
|
||||||
t *testing.T,
|
t.Run("creates and finds env vars", func(t *testing.T) {
|
||||||
wantKey string,
|
t.Parallel()
|
||||||
create func(db *database.Database, appID string) (int64, error),
|
|
||||||
find func(context.Context, *database.Database, string) ([]T, error),
|
|
||||||
keyOf func(T) string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
testDB, cleanup := setupTestDB(t)
|
testDB, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -334,37 +329,21 @@ func testKVCreateAndFind[T any](
|
|||||||
// Create app first.
|
// Create app first.
|
||||||
app := createTestApp(t, testDB)
|
app := createTestApp(t, testDB)
|
||||||
|
|
||||||
id, err := create(testDB, app.ID)
|
envVar := models.NewEnvVar(testDB)
|
||||||
require.NoError(t, err)
|
envVar.AppID = app.ID
|
||||||
assert.NotZero(t, id)
|
|
||||||
|
|
||||||
found, err := find(context.Background(), testDB, app.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, found, 1)
|
|
||||||
assert.Equal(t, wantKey, keyOf(found[0]))
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveTestEnvVar(db *database.Database, appID string) (int64, error) {
|
|
||||||
envVar := models.NewEnvVar(db)
|
|
||||||
envVar.AppID = appID
|
|
||||||
envVar.Key = "DATABASE_URL"
|
envVar.Key = "DATABASE_URL"
|
||||||
envVar.Value = "postgres://localhost/db"
|
envVar.Value = "postgres://localhost/db"
|
||||||
|
|
||||||
err := envVar.Save(context.Background())
|
err := envVar.Save(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotZero(t, envVar.ID)
|
||||||
|
|
||||||
return envVar.ID, err
|
envVars, err := models.FindEnvVarsByAppID(
|
||||||
}
|
context.Background(), testDB, app.ID,
|
||||||
|
|
||||||
func TestEnvVarCRUD(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
t.Run("creates and finds env vars", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testKVCreateAndFind(t, "DATABASE_URL", saveTestEnvVar,
|
|
||||||
models.FindEnvVarsByAppID,
|
|
||||||
func(e *models.EnvVar) string { return e.Key },
|
|
||||||
)
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, envVars, 1)
|
||||||
|
assert.Equal(t, "DATABASE_URL", envVars[0].Key)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("deletes env var", func(t *testing.T) {
|
t.Run("deletes env var", func(t *testing.T) {
|
||||||
@@ -396,27 +375,32 @@ func TestEnvVarCRUD(t *testing.T) {
|
|||||||
|
|
||||||
// Label Tests.
|
// Label Tests.
|
||||||
|
|
||||||
func saveTestLabel(db *database.Database, appID string) (int64, error) {
|
|
||||||
label := models.NewLabel(db)
|
|
||||||
label.AppID = appID
|
|
||||||
label.Key = "traefik.enable"
|
|
||||||
label.Value = "true"
|
|
||||||
|
|
||||||
err := label.Save(context.Background())
|
|
||||||
|
|
||||||
return label.ID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLabelCRUD(t *testing.T) {
|
func TestLabelCRUD(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
t.Run("creates and finds labels", func(t *testing.T) {
|
t.Run("creates and finds labels", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
testKVCreateAndFind(t, "traefik.enable", saveTestLabel,
|
testDB, cleanup := setupTestDB(t)
|
||||||
models.FindLabelsByAppID,
|
defer cleanup()
|
||||||
func(l *models.Label) string { return l.Key },
|
|
||||||
|
app := createTestApp(t, testDB)
|
||||||
|
|
||||||
|
label := models.NewLabel(testDB)
|
||||||
|
label.AppID = app.ID
|
||||||
|
label.Key = "traefik.enable"
|
||||||
|
label.Value = "true"
|
||||||
|
|
||||||
|
err := label.Save(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotZero(t, label.ID)
|
||||||
|
|
||||||
|
labels, err := models.FindLabelsByAppID(
|
||||||
|
context.Background(), testDB, app.ID,
|
||||||
)
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, labels, 1)
|
||||||
|
assert.Equal(t, "traefik.enable", labels[0].Key)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,9 +569,7 @@ func TestDeploymentFindByAppID(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deployments, err := models.FindDeploymentsByAppID(
|
deployments, err := models.FindDeploymentsByAppID(context.Background(), testDB, app.ID, 3)
|
||||||
context.Background(), testDB, app.ID, 3,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, deployments, 3)
|
assert.Len(t, deployments, 3)
|
||||||
}
|
}
|
||||||
@@ -724,6 +706,7 @@ func TestAppGetWebhookEvents(t *testing.T) {
|
|||||||
|
|
||||||
// Cascade Delete Tests.
|
// Cascade Delete Tests.
|
||||||
|
|
||||||
|
//nolint:funlen // Test function with many assertions - acceptable for integration tests
|
||||||
func TestCascadeDelete(t *testing.T) {
|
func TestCascadeDelete(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -798,97 +781,6 @@ func TestCascadeDelete(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resource Limits Tests.
|
|
||||||
|
|
||||||
//nolint:funlen // integration test with multiple subtests
|
|
||||||
func TestAppResourceLimits(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
t.Run("saves and loads CPU limit", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testDB, cleanup := setupTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
app := createTestApp(t, testDB)
|
|
||||||
|
|
||||||
app.CPULimit = sql.NullFloat64{Float64: 0.5, Valid: true}
|
|
||||||
|
|
||||||
err := app.Save(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
found, err := models.FindApp(context.Background(), testDB, app.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, found)
|
|
||||||
assert.True(t, found.CPULimit.Valid)
|
|
||||||
assert.InDelta(t, 0.5, found.CPULimit.Float64, 0.001)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("saves and loads memory limit", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testDB, cleanup := setupTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
app := createTestApp(t, testDB)
|
|
||||||
|
|
||||||
app.MemoryLimit = sql.NullInt64{Int64: 536870912, Valid: true} // 512m
|
|
||||||
|
|
||||||
err := app.Save(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
found, err := models.FindApp(context.Background(), testDB, app.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, found)
|
|
||||||
assert.True(t, found.MemoryLimit.Valid)
|
|
||||||
assert.Equal(t, int64(536870912), found.MemoryLimit.Int64)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("null limits by default", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testDB, cleanup := setupTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
app := createTestApp(t, testDB)
|
|
||||||
|
|
||||||
found, err := models.FindApp(context.Background(), testDB, app.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, found)
|
|
||||||
assert.False(t, found.CPULimit.Valid)
|
|
||||||
assert.False(t, found.MemoryLimit.Valid)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("clears limits when set to null", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
testDB, cleanup := setupTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
app := createTestApp(t, testDB)
|
|
||||||
|
|
||||||
// Set limits
|
|
||||||
app.CPULimit = sql.NullFloat64{Float64: 1.0, Valid: true}
|
|
||||||
app.MemoryLimit = sql.NullInt64{Int64: 1073741824, Valid: true} // 1g
|
|
||||||
|
|
||||||
err := app.Save(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Clear limits
|
|
||||||
app.CPULimit = sql.NullFloat64{}
|
|
||||||
app.MemoryLimit = sql.NullInt64{}
|
|
||||||
|
|
||||||
err = app.Save(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
found, err := models.FindApp(context.Background(), testDB, app.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, found)
|
|
||||||
assert.False(t, found.CPULimit.Valid)
|
|
||||||
assert.False(t, found.MemoryLimit.Valid)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to create a test app.
|
// Helper function to create a test app.
|
||||||
func createTestApp(t *testing.T, testDB *database.Database) *models.App {
|
func createTestApp(t *testing.T, testDB *database.Database) *models.App {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PortProtocol represents the protocol for a port mapping.
|
// PortProtocol represents the protocol for a port mapping.
|
||||||
@@ -112,12 +112,6 @@ func FindPort(
|
|||||||
return port, nil
|
return port, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Port) scanDest() []any {
|
|
||||||
return []any{
|
|
||||||
&p.ID, &p.AppID, &p.HostPort, &p.ContainerPort, &p.Protocol,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindPortsByAppID finds all ports for an app.
|
// FindPortsByAppID finds all ports for an app.
|
||||||
func FindPortsByAppID(
|
func FindPortsByAppID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -128,7 +122,30 @@ func FindPortsByAppID(
|
|||||||
SELECT id, app_id, host_port, container_port, protocol
|
SELECT id, app_id, host_port, container_port, protocol
|
||||||
FROM app_ports WHERE app_id = ? ORDER BY host_port`
|
FROM app_ports WHERE app_id = ? ORDER BY host_port`
|
||||||
|
|
||||||
return findAllByAppID(ctx, db, query, appID, "ports", NewPort)
|
rows, err := db.Query(ctx, query, appID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying ports by app: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var ports []*Port
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
port := NewPort(db)
|
||||||
|
|
||||||
|
scanErr := rows.Scan(
|
||||||
|
&port.ID, &port.AppID, &port.HostPort,
|
||||||
|
&port.ContainerPort, &port.Protocol,
|
||||||
|
)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
|
||||||
|
ports = append(ports, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ports, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeletePortsByAppID deletes all ports for an app.
|
// DeletePortsByAppID deletes all ports for an app.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// User represents a user in the system.
|
// User represents a user in the system.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Volume represents a volume mount for an app container.
|
// Volume represents a volume mount for an app container.
|
||||||
@@ -103,12 +103,6 @@ func FindVolume(
|
|||||||
return vol, nil
|
return vol, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Volume) scanDest() []any {
|
|
||||||
return []any{
|
|
||||||
&v.ID, &v.AppID, &v.HostPath, &v.ContainerPath, &v.ReadOnly,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindVolumesByAppID finds all volumes for an app.
|
// FindVolumesByAppID finds all volumes for an app.
|
||||||
func FindVolumesByAppID(
|
func FindVolumesByAppID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -119,7 +113,30 @@ func FindVolumesByAppID(
|
|||||||
SELECT id, app_id, host_path, container_path, readonly
|
SELECT id, app_id, host_path, container_path, readonly
|
||||||
FROM app_volumes WHERE app_id = ? ORDER BY container_path`
|
FROM app_volumes WHERE app_id = ? ORDER BY container_path`
|
||||||
|
|
||||||
return findAllByAppID(ctx, db, query, appID, "volumes", NewVolume)
|
rows, err := db.Query(ctx, query, appID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying volumes by app: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var volumes []*Volume
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
vol := NewVolume(db)
|
||||||
|
|
||||||
|
scanErr := rows.Scan(
|
||||||
|
&vol.ID, &vol.AppID, &vol.HostPath,
|
||||||
|
&vol.ContainerPath, &vol.ReadOnly,
|
||||||
|
)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
|
||||||
|
volumes = append(volumes, vol)
|
||||||
|
}
|
||||||
|
|
||||||
|
return volumes, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteVolumesByAppID deletes all volumes for an app.
|
// DeleteVolumesByAppID deletes all volumes for an app.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebhookEvent represents a received webhook event.
|
// WebhookEvent represents a received webhook event.
|
||||||
@@ -52,20 +52,6 @@ func (w *WebhookEvent) Reload(ctx context.Context) error {
|
|||||||
return w.scan(row)
|
return w.scan(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShortCommit returns a truncated commit SHA for display.
|
|
||||||
func (w *WebhookEvent) ShortCommit() string {
|
|
||||||
if !w.CommitSHA.Valid {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
sha := w.CommitSHA.String
|
|
||||||
if len(sha) > shortCommitLength {
|
|
||||||
return sha[:shortCommitLength]
|
|
||||||
}
|
|
||||||
|
|
||||||
return sha
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookEvent) insert(ctx context.Context) error {
|
func (w *WebhookEvent) insert(ctx context.Context) error {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO webhook_events (
|
INSERT INTO webhook_events (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/static"
|
"git.eeqj.de/sneak/upaas/static"
|
||||||
)
|
)
|
||||||
|
|
||||||
// requestTimeout is the maximum duration for handling a request.
|
// requestTimeout is the maximum duration for handling a request.
|
||||||
@@ -70,15 +70,8 @@ func (s *Server) SetupRoutes() {
|
|||||||
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
|
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
|
||||||
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
|
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
|
||||||
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
|
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
|
||||||
r.Get("/apps/{id}/webhooks", s.handlers.HandleAppWebhookEvents())
|
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
|
||||||
r.Get(
|
r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
|
||||||
"/apps/{id}/deployments/{deploymentID}/logs",
|
|
||||||
s.handlers.HandleDeploymentLogsAPI(),
|
|
||||||
)
|
|
||||||
r.Get(
|
|
||||||
"/apps/{id}/deployments/{deploymentID}/download",
|
|
||||||
s.handlers.HandleDeploymentLogDownload(),
|
|
||||||
)
|
|
||||||
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
|
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
|
||||||
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
|
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
|
||||||
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
|
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
|
||||||
@@ -88,8 +81,10 @@ func (s *Server) SetupRoutes() {
|
|||||||
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
|
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
|
||||||
r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
|
r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
|
||||||
|
|
||||||
// Environment variables (monolithic bulk save)
|
// Environment variables
|
||||||
r.Post("/apps/{id}/env", s.handlers.HandleEnvVarSave())
|
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
|
||||||
|
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
|
||||||
|
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
|
||||||
|
|
||||||
// Labels
|
// Labels
|
||||||
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
|
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
|
||||||
@@ -119,7 +114,10 @@ func (s *Server) SetupRoutes() {
|
|||||||
r.Get("/whoami", s.handlers.HandleAPIWhoAmI())
|
r.Get("/whoami", s.handlers.HandleAPIWhoAmI())
|
||||||
|
|
||||||
r.Get("/apps", s.handlers.HandleAPIListApps())
|
r.Get("/apps", s.handlers.HandleAPIListApps())
|
||||||
|
r.Post("/apps", s.handlers.HandleAPICreateApp())
|
||||||
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp())
|
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp())
|
||||||
|
r.Delete("/apps/{id}", s.handlers.HandleAPIDeleteApp())
|
||||||
|
r.Post("/apps/{id}/deploy", s.handlers.HandleAPITriggerDeploy())
|
||||||
r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments())
|
r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
"sneak.berlin/go/upaas/internal/globals"
|
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||||
"sneak.berlin/go/upaas/internal/handlers"
|
"git.eeqj.de/sneak/upaas/internal/handlers"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/middleware"
|
"git.eeqj.de/sneak/upaas/internal/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params contains dependencies for Server.
|
// Params contains dependencies for Server.
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/models"
|
"git.eeqj.de/sneak/upaas/internal/models"
|
||||||
"sneak.berlin/go/upaas/internal/ssh"
|
"git.eeqj.de/sneak/upaas/internal/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServiceParams contains dependencies for Service.
|
// ServiceParams contains dependencies for Service.
|
||||||
|
|||||||
@@ -8,20 +8,14 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
"sneak.berlin/go/upaas/internal/globals"
|
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/models"
|
"git.eeqj.de/sneak/upaas/internal/models"
|
||||||
"sneak.berlin/go/upaas/internal/service/app"
|
"git.eeqj.de/sneak/upaas/internal/service/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testRepoURL is the default repository URL used across tests.
|
|
||||||
const testRepoURL = "git@example.com:user/repo.git"
|
|
||||||
|
|
||||||
// giteaRepoURL is the gitea repository URL used across tests.
|
|
||||||
const giteaRepoURL = "git@gitea.example.com:user/repo.git"
|
|
||||||
|
|
||||||
func setupTestService(t *testing.T) (*app.Service, func()) {
|
func setupTestService(t *testing.T) (*app.Service, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -64,8 +58,7 @@ func setupTestService(t *testing.T) (*app.Service, func()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// deleteItemTestHelper is a generic helper for testing delete operations.
|
// deleteItemTestHelper is a generic helper for testing delete operations.
|
||||||
// It creates an app, adds an item, verifies it exists, deletes it, and
|
// It creates an app, adds an item, verifies it exists, deletes it, and verifies it's gone.
|
||||||
// verifies it's gone.
|
|
||||||
func deleteItemTestHelper(
|
func deleteItemTestHelper(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
appName string,
|
appName string,
|
||||||
@@ -80,7 +73,7 @@ func deleteItemTestHelper(
|
|||||||
|
|
||||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||||
Name: appName,
|
Name: appName,
|
||||||
RepoURL: testRepoURL,
|
RepoURL: "git@example.com:user/repo.git",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -99,35 +92,6 @@ func deleteItemTestHelper(
|
|||||||
assert.Equal(t, 0, count)
|
assert.Equal(t, 0, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runDeleteItemTest adapts typed list/delete callbacks so delete tests for
|
|
||||||
// different item types can share deleteItemTestHelper.
|
|
||||||
func runDeleteItemTest[T any](
|
|
||||||
t *testing.T,
|
|
||||||
appName string,
|
|
||||||
addItem func(ctx context.Context, svc *app.Service, appID string) error,
|
|
||||||
listItems func(ctx context.Context, application *models.App) ([]T, error),
|
|
||||||
deleteFirst func(ctx context.Context, svc *app.Service, item T) error,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
deleteItemTestHelper(t, appName,
|
|
||||||
addItem,
|
|
||||||
func(ctx context.Context, application *models.App) (int, error) {
|
|
||||||
items, err := listItems(ctx, application)
|
|
||||||
|
|
||||||
return len(items), err
|
|
||||||
},
|
|
||||||
func(ctx context.Context, svc *app.Service, application *models.App) error {
|
|
||||||
items, err := listItems(ctx, application)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return deleteFirst(ctx, svc, items[0])
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateAppWithGeneratedKeys(t *testing.T) {
|
func TestCreateAppWithGeneratedKeys(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -136,7 +100,7 @@ func TestCreateAppWithGeneratedKeys(t *testing.T) {
|
|||||||
|
|
||||||
input := app.CreateAppInput{
|
input := app.CreateAppInput{
|
||||||
Name: "test-app",
|
Name: "test-app",
|
||||||
RepoURL: giteaRepoURL,
|
RepoURL: "git@gitea.example.com:user/repo.git",
|
||||||
Branch: "main",
|
Branch: "main",
|
||||||
DockerfilePath: "Dockerfile",
|
DockerfilePath: "Dockerfile",
|
||||||
}
|
}
|
||||||
@@ -146,7 +110,7 @@ func TestCreateAppWithGeneratedKeys(t *testing.T) {
|
|||||||
require.NotNil(t, createdApp)
|
require.NotNil(t, createdApp)
|
||||||
|
|
||||||
assert.Equal(t, "test-app", createdApp.Name)
|
assert.Equal(t, "test-app", createdApp.Name)
|
||||||
assert.Equal(t, giteaRepoURL, createdApp.RepoURL)
|
assert.Equal(t, "git@gitea.example.com:user/repo.git", createdApp.RepoURL)
|
||||||
assert.Equal(t, "main", createdApp.Branch)
|
assert.Equal(t, "main", createdApp.Branch)
|
||||||
assert.Equal(t, "Dockerfile", createdApp.DockerfilePath)
|
assert.Equal(t, "Dockerfile", createdApp.DockerfilePath)
|
||||||
assert.NotEmpty(t, createdApp.ID)
|
assert.NotEmpty(t, createdApp.ID)
|
||||||
@@ -166,7 +130,7 @@ func TestCreateAppDefaults(t *testing.T) {
|
|||||||
|
|
||||||
input := app.CreateAppInput{
|
input := app.CreateAppInput{
|
||||||
Name: "test-app-defaults",
|
Name: "test-app-defaults",
|
||||||
RepoURL: giteaRepoURL,
|
RepoURL: "git@gitea.example.com:user/repo.git",
|
||||||
}
|
}
|
||||||
|
|
||||||
createdApp, err := svc.CreateApp(context.Background(), input)
|
createdApp, err := svc.CreateApp(context.Background(), input)
|
||||||
@@ -184,7 +148,7 @@ func TestCreateAppOptionalFields(t *testing.T) {
|
|||||||
|
|
||||||
input := app.CreateAppInput{
|
input := app.CreateAppInput{
|
||||||
Name: "test-app-full",
|
Name: "test-app-full",
|
||||||
RepoURL: giteaRepoURL,
|
RepoURL: "git@gitea.example.com:user/repo.git",
|
||||||
Branch: "develop",
|
Branch: "develop",
|
||||||
DockerNetwork: "my-network",
|
DockerNetwork: "my-network",
|
||||||
NtfyTopic: "https://ntfy.sh/my-topic",
|
NtfyTopic: "https://ntfy.sh/my-topic",
|
||||||
@@ -212,7 +176,7 @@ func TestUpdateApp(testingT *testing.T) {
|
|||||||
|
|
||||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||||
Name: "original-name",
|
Name: "original-name",
|
||||||
RepoURL: testRepoURL,
|
RepoURL: "git@example.com:user/repo.git",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -244,7 +208,7 @@ func TestUpdateApp(testingT *testing.T) {
|
|||||||
|
|
||||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||||
Name: "test-clear",
|
Name: "test-clear",
|
||||||
RepoURL: testRepoURL,
|
RepoURL: "git@example.com:user/repo.git",
|
||||||
NtfyTopic: "https://ntfy.sh/topic",
|
NtfyTopic: "https://ntfy.sh/topic",
|
||||||
SlackWebhook: "https://slack.com/hook",
|
SlackWebhook: "https://slack.com/hook",
|
||||||
})
|
})
|
||||||
@@ -252,7 +216,7 @@ func TestUpdateApp(testingT *testing.T) {
|
|||||||
|
|
||||||
err = svc.UpdateApp(context.Background(), createdApp, app.UpdateAppInput{
|
err = svc.UpdateApp(context.Background(), createdApp, app.UpdateAppInput{
|
||||||
Name: "test-clear",
|
Name: "test-clear",
|
||||||
RepoURL: testRepoURL,
|
RepoURL: "git@example.com:user/repo.git",
|
||||||
Branch: "main",
|
Branch: "main",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -276,7 +240,7 @@ func TestDeleteApp(testingT *testing.T) {
|
|||||||
|
|
||||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||||
Name: "to-delete",
|
Name: "to-delete",
|
||||||
RepoURL: testRepoURL,
|
RepoURL: "git@example.com:user/repo.git",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -300,7 +264,7 @@ func TestGetApp(testingT *testing.T) {
|
|||||||
|
|
||||||
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||||
Name: "findable-app",
|
Name: "findable-app",
|
||||||
RepoURL: testRepoURL,
|
RepoURL: "git@example.com:user/repo.git",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -335,7 +299,7 @@ func TestGetAppByWebhookSecret(testingT *testing.T) {
|
|||||||
|
|
||||||
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||||
Name: "webhook-app",
|
Name: "webhook-app",
|
||||||
RepoURL: testRepoURL,
|
RepoURL: "git@example.com:user/repo.git",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -414,7 +378,7 @@ func TestEnvVarsAddAndRetrieve(t *testing.T) {
|
|||||||
|
|
||||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||||
Name: "env-test",
|
Name: "env-test",
|
||||||
RepoURL: testRepoURL,
|
RepoURL: "git@example.com:user/repo.git",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -447,33 +411,29 @@ func TestEnvVarsAddAndRetrieve(t *testing.T) {
|
|||||||
assert.Equal(t, "secret123", keys["API_KEY"])
|
assert.Equal(t, "secret123", keys["API_KEY"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// addDeletableEnvVar seeds the env var removed in the delete test.
|
|
||||||
func addDeletableEnvVar(
|
|
||||||
ctx context.Context, svc *app.Service, appID string,
|
|
||||||
) error {
|
|
||||||
return svc.AddEnvVar(ctx, appID, "TO_DELETE", "value")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnvVarsDelete(t *testing.T) {
|
func TestEnvVarsDelete(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
runDeleteItemTest(t, "env-delete-test", addDeletableEnvVar,
|
deleteItemTestHelper(t, "env-delete-test",
|
||||||
func(ctx context.Context, application *models.App) ([]*models.EnvVar, error) {
|
func(ctx context.Context, svc *app.Service, appID string) error {
|
||||||
return application.GetEnvVars(ctx)
|
return svc.AddEnvVar(ctx, appID, "TO_DELETE", "value")
|
||||||
},
|
},
|
||||||
func(ctx context.Context, svc *app.Service, item *models.EnvVar) error {
|
func(ctx context.Context, application *models.App) (int, error) {
|
||||||
return svc.DeleteEnvVar(ctx, item.ID)
|
envVars, err := application.GetEnvVars(ctx)
|
||||||
|
|
||||||
|
return len(envVars), err
|
||||||
|
},
|
||||||
|
func(ctx context.Context, svc *app.Service, application *models.App) error {
|
||||||
|
envVars, err := application.GetEnvVars(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc.DeleteEnvVar(ctx, envVars[0].ID)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// addDeletableLabel seeds the label removed in the delete test.
|
|
||||||
func addDeletableLabel(
|
|
||||||
ctx context.Context, svc *app.Service, appID string,
|
|
||||||
) error {
|
|
||||||
return svc.AddLabel(ctx, appID, "to.delete", "value")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLabels(testingT *testing.T) {
|
func TestLabels(testingT *testing.T) {
|
||||||
testingT.Parallel()
|
testingT.Parallel()
|
||||||
|
|
||||||
@@ -485,7 +445,7 @@ func TestLabels(testingT *testing.T) {
|
|||||||
|
|
||||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||||
Name: "label-test",
|
Name: "label-test",
|
||||||
RepoURL: testRepoURL,
|
RepoURL: "git@example.com:user/repo.git",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -508,12 +468,22 @@ func TestLabels(testingT *testing.T) {
|
|||||||
testingT.Run("deletes label", func(t *testing.T) {
|
testingT.Run("deletes label", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
runDeleteItemTest(t, "label-delete-test", addDeletableLabel,
|
deleteItemTestHelper(t, "label-delete-test",
|
||||||
func(ctx context.Context, application *models.App) ([]*models.Label, error) {
|
func(ctx context.Context, svc *app.Service, appID string) error {
|
||||||
return application.GetLabels(ctx)
|
return svc.AddLabel(ctx, appID, "to.delete", "value")
|
||||||
},
|
},
|
||||||
func(ctx context.Context, svc *app.Service, item *models.Label) error {
|
func(ctx context.Context, application *models.App) (int, error) {
|
||||||
return svc.DeleteLabel(ctx, item.ID)
|
labels, err := application.GetLabels(ctx)
|
||||||
|
|
||||||
|
return len(labels), err
|
||||||
|
},
|
||||||
|
func(ctx context.Context, svc *app.Service, application *models.App) error {
|
||||||
|
labels, err := application.GetLabels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc.DeleteLabel(ctx, labels[0].ID)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -527,7 +497,7 @@ func TestVolumesAddAndRetrieve(t *testing.T) {
|
|||||||
|
|
||||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||||
Name: "volume-test",
|
Name: "volume-test",
|
||||||
RepoURL: testRepoURL,
|
RepoURL: "git@example.com:user/repo.git",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -577,7 +547,7 @@ func TestVolumesDelete(t *testing.T) {
|
|||||||
|
|
||||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||||
Name: "volume-delete-test",
|
Name: "volume-delete-test",
|
||||||
RepoURL: testRepoURL,
|
RepoURL: "git@example.com:user/repo.git",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -613,7 +583,7 @@ func TestUpdateAppStatus(testingT *testing.T) {
|
|||||||
|
|
||||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||||
Name: "status-test",
|
Name: "status-test",
|
||||||
RepoURL: testRepoURL,
|
RepoURL: "git@example.com:user/repo.git",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, models.AppStatusPending, createdApp.Status)
|
assert.Equal(t, models.AppStatusPending, createdApp.Status)
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"golang.org/x/crypto/argon2"
|
"golang.org/x/crypto/argon2"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/models"
|
"git.eeqj.de/sneak/upaas/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
"sneak.berlin/go/upaas/internal/globals"
|
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/service/auth"
|
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTestService(t *testing.T) (*auth.Service, func()) {
|
func setupTestService(t *testing.T) (*auth.Service, func()) {
|
||||||
@@ -121,7 +121,7 @@ func getSessionCookie(t *testing.T, svc *auth.Service) *http.Cookie {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
|
||||||
err = svc.CreateSession(recorder, request, user)
|
err = svc.CreateSession(recorder, request, user)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -144,11 +144,7 @@ func TestSessionCookieSecureFlag(testingT *testing.T) {
|
|||||||
svc := setupAuthService(t, false)
|
svc := setupAuthService(t, false)
|
||||||
cookie := getSessionCookie(t, svc)
|
cookie := getSessionCookie(t, svc)
|
||||||
require.NotNil(t, cookie, "session cookie should exist")
|
require.NotNil(t, cookie, "session cookie should exist")
|
||||||
assert.True(
|
assert.True(t, cookie.Secure, "session cookie should have Secure flag in production mode")
|
||||||
t,
|
|
||||||
cookie.Secure,
|
|
||||||
"session cookie should have Secure flag in production mode",
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,12 +324,7 @@ func TestCreateUserRaceCondition(testingT *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, 1, successes, "exactly one goroutine should succeed")
|
assert.Equal(t, 1, successes, "exactly one goroutine should succeed")
|
||||||
assert.Equal(
|
assert.Equal(t, goroutines-1, failures, "all other goroutines should fail with ErrUserExists")
|
||||||
t,
|
|
||||||
goroutines-1,
|
|
||||||
failures,
|
|
||||||
"all other goroutines should fail with ErrUserExists",
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,9 +380,7 @@ func TestDestroySessionMaxAge(testingT *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
request := httptest.NewRequestWithContext(
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
t.Context(), http.MethodGet, "/", nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
err := svc.DestroySession(recorder, request)
|
err := svc.DestroySession(recorder, request)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -11,18 +11,17 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
"git.eeqj.de/sneak/upaas/internal/config"
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
"sneak.berlin/go/upaas/internal/docker"
|
"git.eeqj.de/sneak/upaas/internal/docker"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/models"
|
"git.eeqj.de/sneak/upaas/internal/models"
|
||||||
"sneak.berlin/go/upaas/internal/service/notify"
|
"git.eeqj.de/sneak/upaas/internal/service/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Time constants.
|
// Time constants.
|
||||||
@@ -66,8 +65,7 @@ const logFilePermissions = 0o640
|
|||||||
// logTimestampFormat is the format for log file timestamps.
|
// logTimestampFormat is the format for log file timestamps.
|
||||||
const logTimestampFormat = "20060102T150405Z"
|
const logTimestampFormat = "20060102T150405Z"
|
||||||
|
|
||||||
// logFileShortSHALength is the number of characters to use for commit SHA
|
// logFileShortSHALength is the number of characters to use for commit SHA in log filenames.
|
||||||
// in log filenames.
|
|
||||||
const logFileShortSHALength = 12
|
const logFileShortSHALength = 12
|
||||||
|
|
||||||
// dockerLogMessage represents a Docker build log message.
|
// dockerLogMessage represents a Docker build log message.
|
||||||
@@ -88,10 +86,7 @@ type deploymentLogWriter struct {
|
|||||||
flushCtx context.Context //nolint:containedctx // needed for async flush goroutine
|
flushCtx context.Context //nolint:containedctx // needed for async flush goroutine
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDeploymentLogWriter(
|
func newDeploymentLogWriter(ctx context.Context, deployment *models.Deployment) *deploymentLogWriter {
|
||||||
ctx context.Context,
|
|
||||||
deployment *models.Deployment,
|
|
||||||
) *deploymentLogWriter {
|
|
||||||
w := &deploymentLogWriter{
|
w := &deploymentLogWriter{
|
||||||
deployment: deployment,
|
deployment: deployment,
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
@@ -255,16 +250,13 @@ func New(lc fx.Lifecycle, params ServiceParams) (*Service, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetBuildDir returns the build directory path for an app.
|
// GetBuildDir returns the build directory path for an app.
|
||||||
func (svc *Service) GetBuildDir(appName string) string {
|
func (svc *Service) GetBuildDir(appID string) string {
|
||||||
return filepath.Join(svc.config.DataDir, "builds", appName)
|
return filepath.Join(svc.config.DataDir, "builds", appID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogFilePath returns the path to the log file for a deployment.
|
// GetLogFilePath returns the path to the log file for a deployment.
|
||||||
// Returns empty string if the path cannot be determined.
|
// Returns empty string if the path cannot be determined.
|
||||||
func (svc *Service) GetLogFilePath(
|
func (svc *Service) GetLogFilePath(app *models.App, deployment *models.Deployment) string {
|
||||||
app *models.App,
|
|
||||||
deployment *models.Deployment,
|
|
||||||
) string {
|
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hostname = "unknown"
|
hostname = "unknown"
|
||||||
@@ -282,8 +274,7 @@ func (svc *Service) GetLogFilePath(
|
|||||||
// Use started_at timestamp
|
// Use started_at timestamp
|
||||||
timestamp := deployment.StartedAt.UTC().Format(logTimestampFormat)
|
timestamp := deployment.StartedAt.UTC().Format(logTimestampFormat)
|
||||||
|
|
||||||
// Build filename: appname_sha_timestamp.log.txt
|
// Build filename: appname_sha_timestamp.log.txt (or appname_timestamp.log.txt if no SHA)
|
||||||
// (or appname_timestamp.log.txt if no SHA)
|
|
||||||
var filename string
|
var filename string
|
||||||
if sha != "" {
|
if sha != "" {
|
||||||
filename = fmt.Sprintf("%s_%s_%s.log.txt", app.Name, sha, timestamp)
|
filename = fmt.Sprintf("%s_%s_%s.log.txt", app.Name, sha, timestamp)
|
||||||
@@ -316,8 +307,7 @@ func (svc *Service) CancelDeploy(appID string) bool {
|
|||||||
|
|
||||||
// Deploy deploys an app. If cancelExisting is true (e.g. webhook-triggered),
|
// Deploy deploys an app. If cancelExisting is true (e.g. webhook-triggered),
|
||||||
// any in-progress deploy for the same app will be cancelled before starting.
|
// any in-progress deploy for the same app will be cancelled before starting.
|
||||||
// If cancelExisting is false and a deploy is in progress,
|
// If cancelExisting is false and a deploy is in progress, ErrDeploymentInProgress is returned.
|
||||||
// ErrDeploymentInProgress is returned.
|
|
||||||
func (svc *Service) Deploy(
|
func (svc *Service) Deploy(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
@@ -351,8 +341,7 @@ func (svc *Service) Deploy(
|
|||||||
// Fetch webhook event and create deployment record
|
// Fetch webhook event and create deployment record
|
||||||
webhookEvent := svc.fetchWebhookEvent(deployCtx, webhookEventID)
|
webhookEvent := svc.fetchWebhookEvent(deployCtx, webhookEventID)
|
||||||
|
|
||||||
// Use a background context for DB operations that must complete
|
// Use a background context for DB operations that must complete regardless of cancellation
|
||||||
// regardless of cancellation
|
|
||||||
bgCtx := context.WithoutCancel(deployCtx)
|
bgCtx := context.WithoutCancel(deployCtx)
|
||||||
|
|
||||||
deployment, err := svc.createDeploymentRecord(bgCtx, app, webhookEventID, webhookEvent)
|
deployment, err := svc.createDeploymentRecord(bgCtx, app, webhookEventID, webhookEvent)
|
||||||
@@ -411,10 +400,7 @@ func (svc *Service) createRollbackDeployment(
|
|||||||
return nil, fmt.Errorf("failed to create rollback deployment: %w", saveErr)
|
return nil, fmt.Errorf("failed to create rollback deployment: %w", saveErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = deployment.AppendLog(
|
_ = deployment.AppendLog(ctx, "Rolling back to previous image: "+app.PreviousImageID.String)
|
||||||
ctx,
|
|
||||||
"Rolling back to previous image: "+app.PreviousImageID.String,
|
|
||||||
)
|
|
||||||
|
|
||||||
return deployment, nil
|
return deployment, nil
|
||||||
}
|
}
|
||||||
@@ -430,40 +416,28 @@ func (svc *Service) executeRollback(
|
|||||||
|
|
||||||
svc.removeOldContainer(ctx, app, deployment)
|
svc.removeOldContainer(ctx, app, deployment)
|
||||||
|
|
||||||
rollbackOpts, err := svc.buildContainerOptions(
|
rollbackOpts, err := svc.buildContainerOptions(ctx, app, deployment.ID)
|
||||||
ctx,
|
|
||||||
app,
|
|
||||||
docker.ImageID(previousImageID),
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
svc.failDeployment(bgCtx, app, deployment, err)
|
svc.failDeployment(bgCtx, app, deployment, err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to build container options: %w", err)
|
return fmt.Errorf("failed to build container options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rollbackOpts.Image = previousImageID
|
||||||
|
|
||||||
containerID, err := svc.docker.CreateContainer(ctx, rollbackOpts)
|
containerID, err := svc.docker.CreateContainer(ctx, rollbackOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
svc.failDeployment(
|
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to create rollback container: %w", err))
|
||||||
bgCtx,
|
|
||||||
app,
|
|
||||||
deployment,
|
|
||||||
fmt.Errorf("failed to create rollback container: %w", err),
|
|
||||||
)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to create rollback container: %w", err)
|
return fmt.Errorf("failed to create rollback container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deployment.ContainerID = sql.NullString{String: containerID.String(), Valid: true}
|
deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
|
||||||
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID.String())
|
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID)
|
||||||
|
|
||||||
startErr := svc.docker.StartContainer(ctx, containerID)
|
startErr := svc.docker.StartContainer(ctx, containerID)
|
||||||
if startErr != nil {
|
if startErr != nil {
|
||||||
svc.failDeployment(
|
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to start rollback container: %w", startErr))
|
||||||
bgCtx,
|
|
||||||
app,
|
|
||||||
deployment,
|
|
||||||
fmt.Errorf("failed to start rollback container: %w", startErr),
|
|
||||||
)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to start rollback container: %w", startErr)
|
return fmt.Errorf("failed to start rollback container: %w", startErr)
|
||||||
}
|
}
|
||||||
@@ -498,7 +472,7 @@ func (svc *Service) runBuildAndDeploy(
|
|||||||
// Build phase with timeout
|
// Build phase with timeout
|
||||||
imageID, err := svc.buildImageWithTimeout(deployCtx, app, deployment)
|
imageID, err := svc.buildImageWithTimeout(deployCtx, app, deployment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, "")
|
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment)
|
||||||
if cancelErr != nil {
|
if cancelErr != nil {
|
||||||
return cancelErr
|
return cancelErr
|
||||||
}
|
}
|
||||||
@@ -511,7 +485,7 @@ func (svc *Service) runBuildAndDeploy(
|
|||||||
// Deploy phase with timeout
|
// Deploy phase with timeout
|
||||||
err = svc.deployContainerWithTimeout(deployCtx, app, deployment, imageID)
|
err = svc.deployContainerWithTimeout(deployCtx, app, deployment, imageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, imageID)
|
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment)
|
||||||
if cancelErr != nil {
|
if cancelErr != nil {
|
||||||
return cancelErr
|
return cancelErr
|
||||||
}
|
}
|
||||||
@@ -541,7 +515,7 @@ func (svc *Service) buildImageWithTimeout(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
deployment *models.Deployment,
|
deployment *models.Deployment,
|
||||||
) (docker.ImageID, error) {
|
) (string, error) {
|
||||||
buildCtx, cancel := context.WithTimeout(ctx, buildTimeout)
|
buildCtx, cancel := context.WithTimeout(ctx, buildTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -566,7 +540,7 @@ func (svc *Service) deployContainerWithTimeout(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
deployment *models.Deployment,
|
deployment *models.Deployment,
|
||||||
imageID docker.ImageID,
|
imageID string,
|
||||||
) error {
|
) error {
|
||||||
deployCtx, cancel := context.WithTimeout(ctx, deployTimeout)
|
deployCtx, cancel := context.WithTimeout(ctx, deployTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -687,81 +661,24 @@ func (svc *Service) cancelActiveDeploy(appID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkCancelled checks if the deploy context was cancelled (by a newer deploy)
|
// checkCancelled checks if the deploy context was cancelled (by a newer deploy)
|
||||||
// and if so, marks the deployment as cancelled and cleans up orphan resources.
|
// and if so, marks the deployment as cancelled. Returns ErrDeployCancelled or nil.
|
||||||
// Returns ErrDeployCancelled or nil.
|
|
||||||
func (svc *Service) checkCancelled(
|
func (svc *Service) checkCancelled(
|
||||||
deployCtx context.Context,
|
deployCtx context.Context,
|
||||||
bgCtx context.Context,
|
bgCtx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
deployment *models.Deployment,
|
deployment *models.Deployment,
|
||||||
imageID docker.ImageID,
|
|
||||||
) error {
|
) error {
|
||||||
if !errors.Is(deployCtx.Err(), context.Canceled) {
|
if !errors.Is(deployCtx.Err(), context.Canceled) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
svc.log.Info("deployment cancelled", "app", app.Name)
|
svc.log.Info("deployment cancelled by newer deploy", "app", app.Name)
|
||||||
|
|
||||||
svc.cleanupCancelledDeploy(bgCtx, app, deployment, imageID)
|
|
||||||
|
|
||||||
_ = deployment.MarkFinished(bgCtx, models.DeploymentStatusCancelled)
|
_ = deployment.MarkFinished(bgCtx, models.DeploymentStatusCancelled)
|
||||||
|
|
||||||
return ErrDeployCancelled
|
return ErrDeployCancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupCancelledDeploy removes orphan resources left by a cancelled deployment.
|
|
||||||
func (svc *Service) cleanupCancelledDeploy(
|
|
||||||
ctx context.Context,
|
|
||||||
app *models.App,
|
|
||||||
deployment *models.Deployment,
|
|
||||||
imageID docker.ImageID,
|
|
||||||
) {
|
|
||||||
// Clean up the intermediate Docker image if one was built
|
|
||||||
if imageID != "" {
|
|
||||||
removeErr := svc.docker.RemoveImage(ctx, imageID)
|
|
||||||
if removeErr != nil {
|
|
||||||
svc.log.Error("failed to remove image from cancelled deploy",
|
|
||||||
"error", removeErr, "app", app.Name, "image", imageID)
|
|
||||||
_ = deployment.AppendLog(
|
|
||||||
ctx,
|
|
||||||
"WARNING: failed to clean up image "+
|
|
||||||
imageID.String()+": "+removeErr.Error(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
svc.log.Info("cleaned up image from cancelled deploy",
|
|
||||||
"app", app.Name, "image", imageID)
|
|
||||||
_ = deployment.AppendLog(ctx, "Cleaned up intermediate image: "+imageID.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up the build directory for this deployment
|
|
||||||
buildDir := svc.GetBuildDir(app.Name)
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(buildDir)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := fmt.Sprintf("%d-", deployment.ID)
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) {
|
|
||||||
dirPath := filepath.Join(buildDir, entry.Name())
|
|
||||||
|
|
||||||
removeErr := os.RemoveAll(dirPath)
|
|
||||||
if removeErr != nil {
|
|
||||||
svc.log.Error("failed to remove build dir from cancelled deploy",
|
|
||||||
"error", removeErr, "path", dirPath)
|
|
||||||
} else {
|
|
||||||
svc.log.Info("cleaned up build dir from cancelled deploy",
|
|
||||||
"app", app.Name, "path", dirPath)
|
|
||||||
|
|
||||||
_ = deployment.AppendLog(ctx, "Cleaned up build directory")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *Service) fetchWebhookEvent(
|
func (svc *Service) fetchWebhookEvent(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
webhookEventID *int64,
|
webhookEventID *int64,
|
||||||
@@ -847,7 +764,7 @@ func (svc *Service) buildImage(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
deployment *models.Deployment,
|
deployment *models.Deployment,
|
||||||
) (docker.ImageID, error) {
|
) (string, error) {
|
||||||
workDir, cleanup, err := svc.cloneRepository(ctx, app, deployment)
|
workDir, cleanup, err := svc.cloneRepository(ctx, app, deployment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -881,8 +798,8 @@ func (svc *Service) buildImage(
|
|||||||
return "", fmt.Errorf("failed to build image: %w", err)
|
return "", fmt.Errorf("failed to build image: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deployment.ImageID = sql.NullString{String: imageID.String(), Valid: true}
|
deployment.ImageID = sql.NullString{String: imageID, Valid: true}
|
||||||
_ = deployment.AppendLog(ctx, "Image built: "+imageID.String())
|
_ = deployment.AppendLog(ctx, "Image built: "+imageID)
|
||||||
|
|
||||||
return imageID, nil
|
return imageID, nil
|
||||||
}
|
}
|
||||||
@@ -901,24 +818,14 @@ func (svc *Service) cloneRepository(
|
|||||||
|
|
||||||
err := os.MkdirAll(appBuildsDir, buildsDirPermissions)
|
err := os.MkdirAll(appBuildsDir, buildsDirPermissions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
svc.failDeployment(
|
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create builds dir: %w", err))
|
||||||
ctx,
|
|
||||||
app,
|
|
||||||
deployment,
|
|
||||||
fmt.Errorf("failed to create builds dir: %w", err),
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", nil, fmt.Errorf("failed to create builds dir: %w", err)
|
return "", nil, fmt.Errorf("failed to create builds dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
buildDir, err := os.MkdirTemp(appBuildsDir, fmt.Sprintf("%d-*", deployment.ID))
|
buildDir, err := os.MkdirTemp(appBuildsDir, fmt.Sprintf("%d-*", deployment.ID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
svc.failDeployment(
|
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create temp dir: %w", err))
|
||||||
ctx,
|
|
||||||
app,
|
|
||||||
deployment,
|
|
||||||
fmt.Errorf("failed to create temp dir: %w", err),
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
|
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||||
}
|
}
|
||||||
@@ -949,12 +856,7 @@ func (svc *Service) cloneRepository(
|
|||||||
)
|
)
|
||||||
if cloneErr != nil {
|
if cloneErr != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
svc.failDeployment(
|
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to clone repo: %w", cloneErr))
|
||||||
ctx,
|
|
||||||
app,
|
|
||||||
deployment,
|
|
||||||
fmt.Errorf("failed to clone repo: %w", cloneErr),
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr)
|
return "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr)
|
||||||
}
|
}
|
||||||
@@ -1055,16 +957,16 @@ func (svc *Service) removeOldContainer(
|
|||||||
svc.log.Warn("failed to remove old container", "error", removeErr)
|
svc.log.Warn("failed to remove old container", "error", removeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = deployment.AppendLog(ctx, "Old container removed: "+string(containerInfo.ID[:12]))
|
_ = deployment.AppendLog(ctx, "Old container removed: "+containerInfo.ID[:12])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *Service) createAndStartContainer(
|
func (svc *Service) createAndStartContainer(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
deployment *models.Deployment,
|
deployment *models.Deployment,
|
||||||
imageID docker.ImageID,
|
_ string,
|
||||||
) (docker.ContainerID, error) {
|
) (string, error) {
|
||||||
containerOpts, err := svc.buildContainerOptions(ctx, app, imageID)
|
containerOpts, err := svc.buildContainerOptions(ctx, app, deployment.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
svc.failDeployment(ctx, app, deployment, err)
|
svc.failDeployment(ctx, app, deployment, err)
|
||||||
|
|
||||||
@@ -1084,8 +986,8 @@ func (svc *Service) createAndStartContainer(
|
|||||||
return "", fmt.Errorf("failed to create container: %w", err)
|
return "", fmt.Errorf("failed to create container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deployment.ContainerID = sql.NullString{String: containerID.String(), Valid: true}
|
deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
|
||||||
_ = deployment.AppendLog(ctx, "Container created: "+containerID.String())
|
_ = deployment.AppendLog(ctx, "Container created: "+containerID)
|
||||||
|
|
||||||
startErr := svc.docker.StartContainer(ctx, containerID)
|
startErr := svc.docker.StartContainer(ctx, containerID)
|
||||||
if startErr != nil {
|
if startErr != nil {
|
||||||
@@ -1108,7 +1010,7 @@ func (svc *Service) createAndStartContainer(
|
|||||||
func (svc *Service) buildContainerOptions(
|
func (svc *Service) buildContainerOptions(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
imageID docker.ImageID,
|
deploymentID int64,
|
||||||
) (docker.CreateContainerOptions, error) {
|
) (docker.CreateContainerOptions, error) {
|
||||||
envVars, err := app.GetEnvVars(ctx)
|
envVars, err := app.GetEnvVars(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1140,28 +1042,14 @@ func (svc *Service) buildContainerOptions(
|
|||||||
network = app.DockerNetwork.String
|
network = app.DockerNetwork.String
|
||||||
}
|
}
|
||||||
|
|
||||||
var cpuLimit float64
|
|
||||||
|
|
||||||
if app.CPULimit.Valid {
|
|
||||||
cpuLimit = app.CPULimit.Float64
|
|
||||||
}
|
|
||||||
|
|
||||||
var memoryLimit int64
|
|
||||||
|
|
||||||
if app.MemoryLimit.Valid {
|
|
||||||
memoryLimit = app.MemoryLimit.Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
return docker.CreateContainerOptions{
|
return docker.CreateContainerOptions{
|
||||||
Name: "upaas-" + app.Name,
|
Name: "upaas-" + app.Name,
|
||||||
Image: imageID.String(),
|
Image: fmt.Sprintf("upaas-%s:%d", app.Name, deploymentID),
|
||||||
Env: envMap,
|
Env: envMap,
|
||||||
Labels: buildLabelMap(app, labels),
|
Labels: buildLabelMap(app, labels),
|
||||||
Volumes: buildVolumeMounts(volumes),
|
Volumes: buildVolumeMounts(volumes),
|
||||||
Ports: buildPortMappings(ports),
|
Ports: buildPortMappings(ports),
|
||||||
Network: network,
|
Network: network,
|
||||||
CPULimit: cpuLimit,
|
|
||||||
MemoryLimit: memoryLimit,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1206,9 +1094,9 @@ func buildPortMappings(ports []*models.Port) []docker.PortMapping {
|
|||||||
func (svc *Service) updateAppRunning(
|
func (svc *Service) updateAppRunning(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
imageID docker.ImageID,
|
imageID string,
|
||||||
) error {
|
) error {
|
||||||
app.ImageID = sql.NullString{String: imageID.String(), Valid: true}
|
app.ImageID = sql.NullString{String: imageID, Valid: true}
|
||||||
app.Status = models.AppStatusRunning
|
app.Status = models.AppStatusRunning
|
||||||
|
|
||||||
saveErr := app.Save(ctx)
|
saveErr := app.Save(ctx)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCancelActiveDeploy_NoExisting(t *testing.T) {
|
func TestCancelActiveDeploy_NoExisting(t *testing.T) {
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
package deploy_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
|
||||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
cfg := &config.Config{DataDir: tmpDir}
|
|
||||||
|
|
||||||
svc := deploy.NewTestServiceWithConfig(slog.Default(), cfg, nil)
|
|
||||||
|
|
||||||
// Create a fake build directory matching the deployment pattern
|
|
||||||
appName := "test-app"
|
|
||||||
buildDir := svc.GetBuildDirExported(appName)
|
|
||||||
require.NoError(t, os.MkdirAll(buildDir, 0o750))
|
|
||||||
|
|
||||||
// Create deployment-specific dir: <deploymentID>-<random>
|
|
||||||
deployDir := filepath.Join(buildDir, "42-abc123")
|
|
||||||
require.NoError(t, os.MkdirAll(deployDir, 0o750))
|
|
||||||
|
|
||||||
// Create a file inside to verify full removal
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o600),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Also create a dir for a different deployment (should NOT be removed)
|
|
||||||
otherDir := filepath.Join(buildDir, "99-xyz789")
|
|
||||||
require.NoError(t, os.MkdirAll(otherDir, 0o750))
|
|
||||||
|
|
||||||
// Run cleanup for deployment 42
|
|
||||||
svc.CleanupCancelledDeploy(context.Background(), appName, 42, "")
|
|
||||||
|
|
||||||
// Deployment 42's dir should be gone
|
|
||||||
_, err := os.Stat(deployDir)
|
|
||||||
assert.True(t, os.IsNotExist(err), "deployment build dir should be removed")
|
|
||||||
|
|
||||||
// Deployment 99's dir should still exist
|
|
||||||
_, err = os.Stat(otherDir)
|
|
||||||
assert.NoError(t, err, "other deployment build dir should not be removed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCleanupCancelledDeploy_NoBuildDir(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
cfg := &config.Config{DataDir: tmpDir}
|
|
||||||
|
|
||||||
svc := deploy.NewTestServiceWithConfig(slog.Default(), cfg, nil)
|
|
||||||
|
|
||||||
// Should not panic when build dir doesn't exist
|
|
||||||
svc.CleanupCancelledDeploy(context.Background(), "nonexistent-app", 1, "")
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,15 +2,7 @@ package deploy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/config"
|
|
||||||
"sneak.berlin/go/upaas/internal/docker"
|
|
||||||
"sneak.berlin/go/upaas/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewTestService creates a Service with minimal dependencies for testing.
|
// NewTestService creates a Service with minimal dependencies for testing.
|
||||||
@@ -26,11 +18,7 @@ func (svc *Service) CancelActiveDeploy(appID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RegisterActiveDeploy registers an active deploy for testing.
|
// RegisterActiveDeploy registers an active deploy for testing.
|
||||||
func (svc *Service) RegisterActiveDeploy(
|
func (svc *Service) RegisterActiveDeploy(appID string, cancel context.CancelFunc, done chan struct{}) {
|
||||||
appID string,
|
|
||||||
cancel context.CancelFunc,
|
|
||||||
done chan struct{},
|
|
||||||
) {
|
|
||||||
svc.activeDeploys.Store(appID, &activeDeploy{cancel: cancel, done: done})
|
svc.activeDeploys.Store(appID, &activeDeploy{cancel: cancel, done: done})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,58 +31,3 @@ func (svc *Service) TryLockApp(appID string) bool {
|
|||||||
func (svc *Service) UnlockApp(appID string) {
|
func (svc *Service) UnlockApp(appID string) {
|
||||||
svc.unlockApp(appID)
|
svc.unlockApp(appID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTestServiceWithConfig creates a Service with config and docker client for testing.
|
|
||||||
func NewTestServiceWithConfig(
|
|
||||||
log *slog.Logger,
|
|
||||||
cfg *config.Config,
|
|
||||||
dockerClient *docker.Client,
|
|
||||||
) *Service {
|
|
||||||
return &Service{
|
|
||||||
log: log,
|
|
||||||
config: cfg,
|
|
||||||
docker: dockerClient,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CleanupCancelledDeploy exposes the build directory cleanup portion of
|
|
||||||
// cleanupCancelledDeploy for testing. It removes build directories matching
|
|
||||||
// the deployment ID prefix.
|
|
||||||
func (svc *Service) CleanupCancelledDeploy(
|
|
||||||
_ context.Context,
|
|
||||||
appName string,
|
|
||||||
deploymentID int64,
|
|
||||||
_ string,
|
|
||||||
) {
|
|
||||||
// We can't create real models.App/Deployment in tests easily,
|
|
||||||
// so we test the build dir cleanup portion directly.
|
|
||||||
buildDir := svc.GetBuildDir(appName)
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(buildDir)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := fmt.Sprintf("%d-", deploymentID)
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) {
|
|
||||||
dirPath := filepath.Join(buildDir, entry.Name())
|
|
||||||
_ = os.RemoveAll(dirPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBuildDirExported exposes GetBuildDir for testing.
|
|
||||||
func (svc *Service) GetBuildDirExported(appName string) string {
|
|
||||||
return svc.GetBuildDir(appName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildContainerOptionsExported exposes buildContainerOptions for testing.
|
|
||||||
func (svc *Service) BuildContainerOptionsExported(
|
|
||||||
ctx context.Context,
|
|
||||||
app *models.App,
|
|
||||||
imageID docker.ImageID,
|
|
||||||
) (docker.CreateContainerOptions, error) {
|
|
||||||
return svc.buildContainerOptions(ctx, app, imageID)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/models"
|
"git.eeqj.de/sneak/upaas/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTP client timeout.
|
// HTTP client timeout.
|
||||||
@@ -159,8 +158,7 @@ func (svc *Service) NotifyDeployFailed(
|
|||||||
) {
|
) {
|
||||||
duration := time.Since(deployment.StartedAt)
|
duration := time.Since(deployment.StartedAt)
|
||||||
title := "Deploy failed: " + app.Name
|
title := "Deploy failed: " + app.Name
|
||||||
message := "Deployment failed after " + formatDuration(duration) +
|
message := "Deployment failed after " + formatDuration(duration) + ": " + deployErr.Error()
|
||||||
": " + deployErr.Error()
|
|
||||||
|
|
||||||
svc.sendNotifications(ctx, app, title, message, message, "error")
|
svc.sendNotifications(ctx, app, title, message, message, "error")
|
||||||
}
|
}
|
||||||
@@ -249,15 +247,10 @@ func (svc *Service) sendNtfy(
|
|||||||
) error {
|
) error {
|
||||||
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
|
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
|
||||||
|
|
||||||
parsedURL, err := url.ParseRequestURI(topic)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid ntfy topic URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
request, err := http.NewRequestWithContext(
|
request, err := http.NewRequestWithContext(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
parsedURL.String(),
|
topic,
|
||||||
bytes.NewBufferString(message),
|
bytes.NewBufferString(message),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -267,7 +260,6 @@ func (svc *Service) sendNtfy(
|
|||||||
request.Header.Set("Title", title)
|
request.Header.Set("Title", title)
|
||||||
request.Header.Set("Priority", svc.ntfyPriority(priority))
|
request.Header.Set("Priority", svc.ntfyPriority(priority))
|
||||||
|
|
||||||
// #nosec G704 -- URL from validated config, not user input
|
|
||||||
resp, err := svc.client.Do(request)
|
resp, err := svc.client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send ntfy request: %w", err)
|
return fmt.Errorf("failed to send ntfy request: %w", err)
|
||||||
@@ -348,15 +340,10 @@ func (svc *Service) sendSlack(
|
|||||||
return fmt.Errorf("failed to marshal slack payload: %w", err)
|
return fmt.Errorf("failed to marshal slack payload: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedWebhookURL, err := url.ParseRequestURI(webhookURL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid slack webhook URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
request, err := http.NewRequestWithContext(
|
request, err := http.NewRequestWithContext(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
parsedWebhookURL.String(),
|
webhookURL,
|
||||||
bytes.NewBuffer(body),
|
bytes.NewBuffer(body),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -365,7 +352,6 @@ func (svc *Service) sendSlack(
|
|||||||
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
// #nosec G704 -- URL from validated config, not user input
|
|
||||||
resp, err := svc.client.Do(request)
|
resp, err := svc.client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send slack request: %w", err)
|
return fmt.Errorf("failed to send slack request: %w", err)
|
||||||
|
|||||||
@@ -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,16 +4,16 @@ package webhook
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"sneak.berlin/go/upaas/internal/database"
|
"git.eeqj.de/sneak/upaas/internal/database"
|
||||||
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/logger"
|
"git.eeqj.de/sneak/upaas/internal/models"
|
||||||
"sneak.berlin/go/upaas/internal/models"
|
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServiceParams contains dependencies for Service.
|
// ServiceParams contains dependencies for Service.
|
||||||
@@ -43,46 +43,68 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleWebhook processes a webhook request from any supported source
|
// GiteaPushPayload represents a Gitea push webhook payload.
|
||||||
// (Gitea, GitHub, or GitLab). The source parameter determines which
|
//
|
||||||
// payload format to use for parsing.
|
//nolint:tagliatelle // Field names match Gitea API (snake_case)
|
||||||
|
type GiteaPushPayload struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Before string `json:"before"`
|
||||||
|
After string `json:"after"`
|
||||||
|
CompareURL string `json:"compare_url"`
|
||||||
|
Repository struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
CloneURL string `json:"clone_url"`
|
||||||
|
SSHURL string `json:"ssh_url"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
} `json:"repository"`
|
||||||
|
Pusher struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
} `json:"pusher"`
|
||||||
|
Commits []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Author struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
} `json:"author"`
|
||||||
|
} `json:"commits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleWebhook processes a webhook request.
|
||||||
func (svc *Service) HandleWebhook(
|
func (svc *Service) HandleWebhook(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
source Source,
|
|
||||||
eventType string,
|
eventType string,
|
||||||
payload []byte,
|
payload []byte,
|
||||||
) error {
|
) error {
|
||||||
svc.log.Info("processing webhook",
|
svc.log.Info("processing webhook", "app", app.Name, "event", eventType)
|
||||||
"app", app.Name,
|
|
||||||
"source", source.String(),
|
|
||||||
"event", eventType,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Parse payload into normalized push event
|
// Parse payload
|
||||||
pushEvent, parseErr := ParsePushPayload(source, payload)
|
var pushPayload GiteaPushPayload
|
||||||
if parseErr != nil {
|
|
||||||
svc.log.Warn("failed to parse webhook payload",
|
unmarshalErr := json.Unmarshal(payload, &pushPayload)
|
||||||
"error", parseErr,
|
if unmarshalErr != nil {
|
||||||
"source", source.String(),
|
svc.log.Warn("failed to parse webhook payload", "error", unmarshalErr)
|
||||||
)
|
// Continue anyway to log the event
|
||||||
// Continue with empty push event to still log the webhook
|
|
||||||
pushEvent = &PushEvent{Source: source}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract branch from ref
|
||||||
|
branch := extractBranch(pushPayload.Ref)
|
||||||
|
commitSHA := pushPayload.After
|
||||||
|
commitURL := extractCommitURL(pushPayload)
|
||||||
|
|
||||||
// Check if branch matches
|
// Check if branch matches
|
||||||
matched := pushEvent.Branch == app.Branch
|
matched := branch == app.Branch
|
||||||
|
|
||||||
// Create webhook event record
|
// Create webhook event record
|
||||||
event := models.NewWebhookEvent(svc.db)
|
event := models.NewWebhookEvent(svc.db)
|
||||||
event.AppID = app.ID
|
event.AppID = app.ID
|
||||||
event.EventType = eventType
|
event.EventType = eventType
|
||||||
event.Branch = pushEvent.Branch
|
event.Branch = branch
|
||||||
event.CommitSHA = sql.NullString{String: pushEvent.After, Valid: pushEvent.After != ""}
|
event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""}
|
||||||
event.CommitURL = sql.NullString{
|
event.CommitURL = sql.NullString{String: commitURL, Valid: commitURL != ""}
|
||||||
String: pushEvent.CommitURL.String(),
|
|
||||||
Valid: pushEvent.CommitURL != "",
|
|
||||||
}
|
|
||||||
event.Payload = sql.NullString{String: string(payload), Valid: true}
|
event.Payload = sql.NullString{String: string(payload), Valid: true}
|
||||||
event.Matched = matched
|
event.Matched = matched
|
||||||
event.Processed = false
|
event.Processed = false
|
||||||
@@ -94,10 +116,9 @@ func (svc *Service) HandleWebhook(
|
|||||||
|
|
||||||
svc.log.Info("webhook event recorded",
|
svc.log.Info("webhook event recorded",
|
||||||
"app", app.Name,
|
"app", app.Name,
|
||||||
"source", source.String(),
|
"branch", branch,
|
||||||
"branch", pushEvent.Branch,
|
|
||||||
"matched", matched,
|
"matched", matched,
|
||||||
"commit", pushEvent.After,
|
"commit", commitSHA,
|
||||||
)
|
)
|
||||||
|
|
||||||
// If branch matches, trigger deployment
|
// If branch matches, trigger deployment
|
||||||
@@ -132,3 +153,33 @@ func (svc *Service) triggerDeployment(
|
|||||||
_ = event.Save(deployCtx)
|
_ = event.Save(deployCtx)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractBranch extracts the branch name from a git ref.
|
||||||
|
func extractBranch(ref string) string {
|
||||||
|
// refs/heads/main -> main
|
||||||
|
const prefix = "refs/heads/"
|
||||||
|
|
||||||
|
if len(ref) >= len(prefix) && ref[:len(prefix)] == prefix {
|
||||||
|
return ref[len(prefix):]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractCommitURL extracts the commit URL from the webhook payload.
|
||||||
|
// Prefers the URL from the head commit, falls back to constructing from repo URL.
|
||||||
|
func extractCommitURL(payload GiteaPushPayload) string {
|
||||||
|
// Try to find the URL from the head commit (matching After SHA)
|
||||||
|
for _, commit := range payload.Commits {
|
||||||
|
if commit.ID == payload.After && commit.URL != "" {
|
||||||
|
return commit.URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to constructing URL from repo HTML URL
|
||||||
|
if payload.Repository.HTMLURL != "" && payload.After != "" {
|
||||||
|
return payload.Repository.HTMLURL + "/commit/" + payload.After
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// KeyPair contains an SSH key pair.
|
// KeyPair contains an SSH key pair.
|
||||||
type KeyPair struct {
|
type KeyPair struct {
|
||||||
PrivateKey string `json:"-"`
|
PrivateKey string
|
||||||
PublicKey string
|
PublicKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/upaas/internal/ssh"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/upaas/internal/ssh"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGenerateKeyPair(t *testing.T) {
|
func TestGenerateKeyPair(t *testing.T) {
|
||||||
|
|||||||
121
script/bootstrap
121
script/bootstrap
@@ -1,121 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/bootstrap: install all dependencies needed to build and develop
|
|
||||||
# this repo. Idempotent: every install is guarded by a check so already
|
|
||||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
|
||||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
|
||||||
# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt
|
|
||||||
# it is installed from a hash-verified GitHub release archive (never
|
|
||||||
# curl | sh).
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
# Pinned versions, 2026-08-07. Never "latest"; exact versions only.
|
|
||||||
GOLANGCI_LINT_VERSION="2.12.2"
|
|
||||||
# sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
|
|
||||||
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
|
|
||||||
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
|
|
||||||
|
|
||||||
PKGMGR=""
|
|
||||||
SUDO=""
|
|
||||||
|
|
||||||
detect_pkgmgr() {
|
|
||||||
[ -n "$PKGMGR" ] && return 0
|
|
||||||
if command -v nix-env >/dev/null 2>&1; then
|
|
||||||
PKGMGR="nix"
|
|
||||||
elif command -v apt-get >/dev/null 2>&1; then
|
|
||||||
PKGMGR="apt"
|
|
||||||
elif command -v brew >/dev/null 2>&1; then
|
|
||||||
PKGMGR="brew"
|
|
||||||
elif command -v apk >/dev/null 2>&1; then
|
|
||||||
PKGMGR="apk"
|
|
||||||
else
|
|
||||||
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$PKGMGR" = "apt" ]; then
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
|
||||||
if [ "$(id -u)" != "0" ]; then
|
|
||||||
SUDO="sudo"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
|
|
||||||
pkg_install() {
|
|
||||||
detect_pkgmgr
|
|
||||||
case "$PKGMGR" in
|
|
||||||
nix) nix-env -iA "nixpkgs.$1" ;;
|
|
||||||
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
|
|
||||||
brew) brew install "$3" ;;
|
|
||||||
apk) apk add --no-cache "$4" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
missing() {
|
|
||||||
! command -v "$1" >/dev/null 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# verify_sha256 <file> <expected-hash>
|
|
||||||
verify_sha256() {
|
|
||||||
if command -v sha256sum >/dev/null 2>&1; then
|
|
||||||
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
|
||||||
else
|
|
||||||
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
|
||||||
fi
|
|
||||||
if [ "$actual" != "$2" ]; then
|
|
||||||
echo "bootstrap: sha256 mismatch for $1" >&2
|
|
||||||
echo " expected: $2" >&2
|
|
||||||
echo " actual: $actual" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# apt has no golangci-lint package: install a pinned release archive
|
|
||||||
# from GitHub, verified by hardcoded sha256 (never curl | sh).
|
|
||||||
install_golangci_lint_release() {
|
|
||||||
case "$(uname -m)" in
|
|
||||||
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;;
|
|
||||||
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;;
|
|
||||||
*)
|
|
||||||
echo "bootstrap: unsupported architecture $(uname -m)" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
if missing curl; then pkg_install curl curl curl curl; fi
|
|
||||||
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}"
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
curl -fsSL -o "$tmp/$name.tar.gz" \
|
|
||||||
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz"
|
|
||||||
verify_sha256 "$tmp/$name.tar.gz" "$sha"
|
|
||||||
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
|
|
||||||
$SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint
|
|
||||||
rm -rf "$tmp"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_golangci_lint() {
|
|
||||||
if ! missing golangci-lint; then return 0; fi
|
|
||||||
detect_pkgmgr
|
|
||||||
case "$PKGMGR" in
|
|
||||||
apt) install_golangci_lint_release ;;
|
|
||||||
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
|
|
||||||
# Base tooling
|
|
||||||
if missing git; then pkg_install git git git git; fi
|
|
||||||
if missing make; then pkg_install gnumake make make make; fi
|
|
||||||
|
|
||||||
# Go toolchain and linter
|
|
||||||
if missing go; then pkg_install go golang go go; fi
|
|
||||||
ensure_golangci_lint
|
|
||||||
|
|
||||||
go mod download
|
|
||||||
|
|
||||||
echo "bootstrap complete"
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
15
script/check
15
script/check
@@ -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 "$@"
|
|
||||||
14
script/fmt
14
script/fmt
@@ -1,14 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/fmt: format all files (writes).
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
gofmt -s -w .
|
|
||||||
goimports -w .
|
|
||||||
npx prettier --write --tab-width 4 static/js/*.js
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -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 "$@"
|
|
||||||
12
script/lint
12
script/lint
@@ -1,12 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/lint: run the linter.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
golangci-lint run --config .golangci.yml ./...
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -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 "$@"
|
|
||||||
14
script/setup
14
script/setup
@@ -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
script/test
12
script/test
@@ -1,12 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/test: run the test suite.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
go test -v -race -cover -timeout 30s ./...
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
3047
static/js/alpine.min.js
vendored
3047
static/js/alpine.min.js
vendored
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user