Compare commits

..

11 Commits

Author SHA1 Message Date
clawbot
17e9aba63c docs: expand Important note — HOST_DATA_DIR must be absolute path
All checks were successful
Check / check (pull_request) Successful in 11m20s
Explain why relative paths break container builds and add usage example.
Addresses sneak's review feedback on PR #126.
2026-02-26 02:00:58 -08:00
364c2a7abe docs: clarify UPAAS_DATA_DIR default is for local dev only
The ./data default comes from Go code and works for local development.
For Docker deployments, an absolute path should be used.
Updated config table to make this distinction clear.
2026-02-26 02:00:58 -08:00
user
3714ee50ce docs: remove relative path default for HOST_DATA_DIR in docker-compose example
Users must set HOST_DATA_DIR to an explicit absolute path. Removed
the :-./data fallback from both the volume mount and environment
variable in the docker-compose example.
2026-02-26 02:00:58 -08:00
user
ec436f4bf7 refactor: remove internal/domain package, move types to correct packages
- ImageID + ContainerID → internal/docker/types.go
- UnparsedURL → internal/service/webhook/types.go
- Delete internal/domain/ entirely
- Update all imports throughout the codebase
2026-02-26 02:00:58 -08:00
user
38895a1a39 refactor: add String() methods to domain types, replace string() casts 2026-02-26 02:00:58 -08:00
4fc1a0a228 rework: address review feedback on PR #126
Changes per sneak's review:
- Delete docker-compose.yml, add example stanza to README
- Define custom domain types: ImageID, ContainerID, UnparsedURL
- Use custom types in all function signatures throughout codebase
- Restore imageID parameter (as domain.ImageID) in deploy pipeline
- buildContainerOptions now takes ImageID directly instead of
  constructing image tag from deploymentID
- Fix pre-existing JS formatting (prettier)

make check passes with zero failures.
2026-02-26 02:00:58 -08:00
849814a20d fix: assign commit error to err so deferred rollback triggers (closes #125)
When Commit() failed, the error was stored in commitErr instead of err,
so the deferred rollback (which checks err) was skipped.
2026-02-26 02:00:37 -08:00
40f27edaa2 fix: rename GetBuildDir param from appID to appName (closes #123)
The parameter is always called with app.Name, not an ID. Rename to match
actual usage and prevent confusion.
2026-02-26 02:00:37 -08:00
927ac6d35d fix: add 1MB size limit on deployment logs with truncation (closes #122)
Cap AppendLog at 1MB, truncating oldest lines when exceeded. Prevents
unbounded SQLite database growth from long-running builds.
2026-02-26 02:00:37 -08:00
10df8b6ae0 fix: use renderTemplate in all error paths of HandleAppCreate/HandleAppUpdate (closes #121)
Replace direct tmpl.ExecuteTemplate calls with h.renderTemplate to ensure
buffered rendering and prevent partial HTML responses on template errors.
2026-02-26 02:00:37 -08:00
0fc9ee7ed4 fix: use bind mount with HOST_DATA_DIR in docker-compose.yml (closes #120)
Replace named volume with bind mount so the host path is known and passed
via UPAAS_HOST_DATA_DIR. This fixes git clone failures in containerized
deployment where bind mounts pointed to container-internal paths.
2026-02-26 02:00:37 -08:00
71 changed files with 854 additions and 3377 deletions

View File

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

View File

@@ -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

View File

@@ -10,7 +10,17 @@ jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4, 2024-10-13 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Build (runs make check inside Dockerfile) - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
run: docker build . with:
go-version-file: go.mod
- name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee # v2.10.1
- name: Install goimports
run: go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 # v0.42.0
- name: Run make check
run: make check

31
.gitignore vendored
View File

@@ -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

View File

@@ -1,37 +1,26 @@
# Lint stage — fast feedback on formatting and lint issues # Build stage
# golangci/golangci-lint:v2.10.1 FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder # golang:1.25-alpine
FROM golangci/golangci-lint@sha256:ea84d14c2fef724411be7dc45e09e6ef721d748315252b02df19a7e3113ee763 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@5d1e709b7be35cb2025444e19de266b056b7b7ee # v2.10.1
RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 # v0.42.0
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
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@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1 # 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

View File

@@ -1,4 +1,4 @@
.PHONY: all 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")
@@ -18,26 +18,21 @@ fmt:
goimports -w . goimports -w .
npx prettier --write --tab-width 4 static/js/*.js npx prettier --write --tab-width 4 static/js/*.js
fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
test: test:
go test -v -race -cover -timeout 30s ./... 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: fmt-check lint test check:
@echo "==> Checking formatting..."
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
@echo "==> Running linter..."
golangci-lint run --config .golangci.yml ./...
@echo "==> Running tests..."
go test -v -race ./...
@echo "==> Building..."
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/upaasd
@echo "==> All checks passed!" @echo "==> All checks passed!"
docker:
docker build .
hooks:
@echo "Installing pre-commit hook..."
@mkdir -p .git/hooks
@printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "Pre-commit hook installed."
clean: clean:
rm -rf bin/ rm -rf bin/

View File

@@ -11,7 +11,6 @@ A simple self-hosted PaaS that auto-deploys Docker containers from Git repositor
- Environment variables, labels, and volume mounts per app - Environment variables, labels, and volume mounts 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
- Backup/restore of app configurations (JSON export/import via UI and API)
- Simple server-rendered UI with Tailwind CSS - Simple server-rendered UI with Tailwind CSS
## Non-Goals ## Non-Goals
@@ -112,13 +111,10 @@ chi Router ──► Middleware Stack ──► Handler
```bash ```bash
make fmt # Format code make fmt # Format code
make fmt-check # Check formatting (read-only, fails if unformatted)
make lint # Run comprehensive linting make lint # Run comprehensive linting
make test # Run tests with race detection (30s timeout) make test # Run tests with race detection
make check # Verify everything passes (fmt-check, lint, test) 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 make check)
``` ```
### Commit Requirements ### Commit Requirements

View File

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

View File

@@ -4,20 +4,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"
) )

4
go.mod
View File

@@ -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

View File

@@ -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.

View File

@@ -14,8 +14,8 @@ import (
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
"go.uber.org/fx" "go.uber.org/fx"
"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.

View File

@@ -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) {

View File

@@ -5,8 +5,8 @@ import (
"os" "os"
"testing" "testing"
"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"
) )
// NewTestDatabase creates an in-memory Database for testing. // NewTestDatabase creates an in-memory Database for testing.

View File

@@ -25,9 +25,9 @@ import (
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
"go.uber.org/fx" "go.uber.org/fx"
"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"
) )
// sshKeyPermissions is the file permission for SSH private keys. // sshKeyPermissions is the file permission for SSH private keys.

View File

@@ -7,7 +7,7 @@ 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"
) )
// apiAppResponse is the JSON representation of an app. // apiAppResponse is the JSON representation of an app.

View File

@@ -11,7 +11,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/service/app" "git.eeqj.de/sneak/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.
@@ -27,11 +27,6 @@ func apiRouter(tc *testContext) http.Handler {
apiR.Get("/apps", tc.handlers.HandleAPIListApps()) apiR.Get("/apps", tc.handlers.HandleAPIListApps())
apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp()) apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp())
apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments()) apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments())
// Backup/Restore API
apiR.Get("/apps/{id}/export", tc.handlers.HandleAPIExportApp())
apiR.Get("/backup/export", tc.handlers.HandleAPIExportAllApps())
apiR.Post("/backup/import", tc.handlers.HandleAPIImportApps())
}) })
}) })

View File

@@ -15,9 +15,9 @@ 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/app" "git.eeqj.de/sneak/upaas/internal/service/app"
"sneak.berlin/go/upaas/templates" "git.eeqj.de/sneak/upaas/templates"
) )
const ( const (
@@ -54,18 +54,12 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
repoURL := request.FormValue("repo_url") repoURL := request.FormValue("repo_url")
branch := request.FormValue("branch") branch := request.FormValue("branch")
dockerfilePath := request.FormValue("dockerfile_path") dockerfilePath := request.FormValue("dockerfile_path")
dockerNetwork := request.FormValue("docker_network")
ntfyTopic := request.FormValue("ntfy_topic")
slackWebhook := request.FormValue("slack_webhook")
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"Name": name, "Name": name,
"RepoURL": repoURL, "RepoURL": repoURL,
"Branch": branch, "Branch": branch,
"DockerfilePath": dockerfilePath, "DockerfilePath": dockerfilePath,
"DockerNetwork": dockerNetwork,
"NtfyTopic": ntfyTopic,
"SlackWebhook": slackWebhook,
}, request) }, request)
if name == "" || repoURL == "" { if name == "" || repoURL == "" {
@@ -106,9 +100,6 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
RepoURL: repoURL, RepoURL: repoURL,
Branch: branch, Branch: branch,
DockerfilePath: dockerfilePath, DockerfilePath: dockerfilePath,
DockerNetwork: dockerNetwork,
NtfyTopic: ntfyTopic,
SlackWebhook: slackWebhook,
}, },
) )
if createErr != nil { if createErr != nil {
@@ -903,92 +894,50 @@ func (h *Handlers) addKeyValueToApp(
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) 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, "varID")
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{
"error": "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)
"error": 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{
"error": "failed to save environment variables",
}, http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, map[string]bool{"ok": true}, http.StatusOK)
} }
} }
@@ -1247,6 +1196,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) {

View File

@@ -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.

View File

@@ -1,282 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app"
"sneak.berlin/go/upaas/templates"
)
// importMaxBodyBytes is the maximum allowed request body size for backup import (10 MB).
const importMaxBodyBytes = 10 << 20
// HandleExportApp exports a single app's configuration as a JSON download.
func (h *Handlers) HandleExportApp() http.HandlerFunc {
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 || application == nil {
http.NotFound(writer, request)
return
}
bundle, exportErr := h.appService.ExportApp(request.Context(), application)
if exportErr != nil {
h.log.Error("failed to export app", "error", exportErr, "app", application.Name)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
filename := fmt.Sprintf("upaas-backup-%s-%s.json",
application.Name,
time.Now().UTC().Format("20060102-150405"),
)
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Content-Disposition",
`attachment; filename="`+filename+`"`)
encoder := json.NewEncoder(writer)
encoder.SetIndent("", " ")
err := encoder.Encode(bundle)
if err != nil {
h.log.Error("failed to encode backup", "error", err)
}
}
}
// HandleExportAllApps exports all app configurations as a JSON download.
func (h *Handlers) HandleExportAllApps() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
bundle, exportErr := h.appService.ExportAllApps(request.Context())
if exportErr != nil {
h.log.Error("failed to export all apps", "error", exportErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
filename := fmt.Sprintf("upaas-backup-all-%s.json",
time.Now().UTC().Format("20060102-150405"),
)
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Content-Disposition",
`attachment; filename="`+filename+`"`)
encoder := json.NewEncoder(writer)
encoder.SetIndent("", " ")
err := encoder.Encode(bundle)
if err != nil {
h.log.Error("failed to encode backup", "error", err)
}
}
}
// HandleImportPage renders the import/restore page.
func (h *Handlers) HandleImportPage() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
data := h.addGlobals(map[string]any{
"Success": request.URL.Query().Get("success"),
}, request)
h.renderTemplate(writer, tmpl, "backup_import.html", data)
}
}
// HandleImportApps processes an uploaded backup JSON file and imports apps.
func (h *Handlers) HandleImportApps() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
bundle, parseErr := h.parseBackupUpload(request)
if parseErr != "" {
data := h.addGlobals(map[string]any{"Error": parseErr}, request)
h.renderTemplate(writer, tmpl, "backup_import.html", data)
return
}
imported, skipped, importErr := h.appService.ImportApps(
request.Context(), bundle,
)
if importErr != nil {
h.log.Error("failed to import apps", "error", importErr)
data := h.addGlobals(map[string]any{
"Error": "Import failed: " + importErr.Error(),
}, request)
h.renderTemplate(writer, tmpl, "backup_import.html", data)
return
}
successMsg := fmt.Sprintf("Imported %d app(s)", len(imported))
if len(skipped) > 0 {
successMsg += fmt.Sprintf(", skipped %d (name conflict)", len(skipped))
}
http.Redirect(writer, request,
"/backup/import?success="+successMsg,
http.StatusSeeOther,
)
}
}
// parseBackupUpload extracts and validates a BackupBundle from a multipart upload.
// Returns the bundle and an empty string on success, or nil and an error message.
func (h *Handlers) parseBackupUpload(
request *http.Request,
) (*app.BackupBundle, string) {
request.Body = http.MaxBytesReader(nil, request.Body, importMaxBodyBytes)
parseErr := request.ParseMultipartForm(importMaxBodyBytes)
if parseErr != nil {
return nil, "Failed to parse upload: " + parseErr.Error()
}
file, _, openErr := request.FormFile("backup_file")
if openErr != nil {
return nil, "Please select a backup file to import"
}
defer func() { _ = file.Close() }()
var bundle app.BackupBundle
decodeErr := json.NewDecoder(file).Decode(&bundle)
if decodeErr != nil {
return nil, "Invalid backup file: " + decodeErr.Error()
}
if bundle.Version != 1 {
return nil, fmt.Sprintf(
"Unsupported backup version: %d (expected 1)", bundle.Version,
)
}
if len(bundle.Apps) == 0 {
return nil, "Backup file contains no apps"
}
return &bundle, ""
}
// HandleAPIExportApp exports a single app's configuration as JSON via API.
func (h *Handlers) HandleAPIExportApp() 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
}
bundle, exportErr := h.appService.ExportApp(request.Context(), application)
if exportErr != nil {
h.log.Error("failed to export app", "error", exportErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to export app"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, bundle, http.StatusOK)
}
}
// HandleAPIExportAllApps exports all app configurations as JSON via API.
func (h *Handlers) HandleAPIExportAllApps() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
bundle, exportErr := h.appService.ExportAllApps(request.Context())
if exportErr != nil {
h.log.Error("failed to export all apps", "error", exportErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to export apps"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, bundle, http.StatusOK)
}
}
// HandleAPIImportApps imports app configurations from a JSON request body via API.
func (h *Handlers) HandleAPIImportApps() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
request.Body = http.MaxBytesReader(writer, request.Body, importMaxBodyBytes)
var bundle app.BackupBundle
decodeErr := json.NewDecoder(request.Body).Decode(&bundle)
if decodeErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid request body"},
http.StatusBadRequest)
return
}
if bundle.Version != 1 {
h.respondJSON(writer, request,
map[string]string{"error": fmt.Sprintf(
"unsupported backup version: %d", bundle.Version,
)},
http.StatusBadRequest)
return
}
if len(bundle.Apps) == 0 {
h.respondJSON(writer, request,
map[string]string{"error": "backup contains no apps"},
http.StatusBadRequest)
return
}
imported, skipped, importErr := h.appService.ImportApps(
request.Context(), &bundle,
)
if importErr != nil {
h.log.Error("api: failed to import apps", "error", importErr)
h.respondJSON(writer, request,
map[string]string{"error": "import failed: " + importErr.Error()},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, map[string]any{
"imported": imported,
"skipped": skipped,
}, http.StatusOK)
}
}

View File

@@ -1,582 +0,0 @@
package handlers_test
import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app"
)
// createTestAppWithConfig creates an app with env vars, labels, volumes, and ports.
func createTestAppWithConfig(
t *testing.T,
tc *testContext,
name string,
) *models.App {
t.Helper()
createdApp := createTestApp(t, tc, name)
// Add env vars
ev := models.NewEnvVar(tc.database)
ev.AppID = createdApp.ID
ev.Key = "DATABASE_URL"
ev.Value = "postgres://localhost/mydb"
require.NoError(t, ev.Save(context.Background()))
// Add label
label := models.NewLabel(tc.database)
label.AppID = createdApp.ID
label.Key = "traefik.enable"
label.Value = "true"
require.NoError(t, label.Save(context.Background()))
// Add volume
volume := models.NewVolume(tc.database)
volume.AppID = createdApp.ID
volume.HostPath = "/data/app"
volume.ContainerPath = "/app/data"
volume.ReadOnly = false
require.NoError(t, volume.Save(context.Background()))
// Add port
port := models.NewPort(tc.database)
port.AppID = createdApp.ID
port.HostPort = 8080
port.ContainerPort = 80
port.Protocol = models.PortProtocolTCP
require.NoError(t, port.Save(context.Background()))
return createdApp
}
// createTestAppWithConfigPort creates an app with a custom host port.
func createTestAppWithConfigPort(
t *testing.T,
tc *testContext,
name string,
hostPort int,
) *models.App {
t.Helper()
createdApp := createTestApp(t, tc, name)
ev := models.NewEnvVar(tc.database)
ev.AppID = createdApp.ID
ev.Key = "DATABASE_URL"
ev.Value = "postgres://localhost/mydb"
require.NoError(t, ev.Save(context.Background()))
port := models.NewPort(tc.database)
port.AppID = createdApp.ID
port.HostPort = hostPort
port.ContainerPort = 80
port.Protocol = models.PortProtocolTCP
require.NoError(t, port.Save(context.Background()))
return createdApp
}
func TestHandleExportApp(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestAppWithConfig(t, testCtx, "export-test-app")
request := httptest.NewRequest(http.MethodGet, "/apps/"+createdApp.ID+"/export", nil)
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleExportApp()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Header().Get("Content-Type"), "application/json")
assert.Contains(t, recorder.Header().Get("Content-Disposition"), "attachment")
assert.Contains(t, recorder.Header().Get("Content-Disposition"), "export-test-app")
var bundle app.BackupBundle
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &bundle))
assert.Equal(t, 1, bundle.Version)
assert.NotEmpty(t, bundle.ExportedAt)
require.Len(t, bundle.Apps, 1)
appBackup := bundle.Apps[0]
assert.Equal(t, "export-test-app", appBackup.Name)
assert.Equal(t, "main", appBackup.Branch)
assert.Len(t, appBackup.EnvVars, 1)
assert.Equal(t, "DATABASE_URL", appBackup.EnvVars[0].Key)
assert.Equal(t, "postgres://localhost/mydb", appBackup.EnvVars[0].Value)
assert.Len(t, appBackup.Labels, 1)
assert.Equal(t, "traefik.enable", appBackup.Labels[0].Key)
assert.Len(t, appBackup.Volumes, 1)
assert.Equal(t, "/data/app", appBackup.Volumes[0].HostPath)
assert.Len(t, appBackup.Ports, 1)
assert.Equal(t, 8080, appBackup.Ports[0].HostPort)
}
func TestHandleExportAppNotFound(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/apps/nonexistent/export", nil)
request = addChiURLParams(request, map[string]string{"id": "nonexistent"})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleExportApp()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusNotFound, recorder.Code)
}
func TestHandleExportAllApps(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createTestAppWithConfig(t, testCtx, "export-all-app1")
createTestAppWithConfigPort(t, testCtx, "export-all-app2", 8081)
request := httptest.NewRequest(http.MethodGet, "/backup/export", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleExportAllApps()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Header().Get("Content-Disposition"), "upaas-backup-all")
var bundle app.BackupBundle
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &bundle))
assert.Equal(t, 1, bundle.Version)
assert.Len(t, bundle.Apps, 2)
}
func TestHandleExportAllAppsEmpty(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/backup/export", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleExportAllApps()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
var bundle app.BackupBundle
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &bundle))
assert.Empty(t, bundle.Apps)
}
// createMultipartBackupRequest builds a multipart form request with backup JSON as a file upload.
func createMultipartBackupRequest(
t *testing.T,
backupJSON string,
) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("backup_file", "backup.json")
require.NoError(t, err)
_, err = io.WriteString(part, backupJSON)
require.NoError(t, err)
require.NoError(t, writer.Close())
request := httptest.NewRequest(http.MethodPost, "/backup/import", &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
return request
}
func TestHandleImportApps(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
backupJSON := `{
"version": 1,
"exportedAt": "2025-01-01T00:00:00Z",
"apps": [{
"name": "imported-app",
"repoUrl": "git@example.com:user/repo.git",
"branch": "main",
"dockerfilePath": "Dockerfile",
"envVars": [{"key": "FOO", "value": "bar"}],
"labels": [{"key": "app.name", "value": "test"}],
"volumes": [{"hostPath": "/data", "containerPath": "/app/data", "readOnly": true}],
"ports": [{"hostPort": 3000, "containerPort": 8080, "protocol": "tcp"}]
}]
}`
request := createMultipartBackupRequest(t, backupJSON)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleImportApps()
handler.ServeHTTP(recorder, request)
// Should redirect on success
assert.Equal(t, http.StatusSeeOther, recorder.Code)
assert.Contains(t, recorder.Header().Get("Location"), "success=")
// Verify the app was created
apps, err := models.AllApps(context.Background(), testCtx.database)
require.NoError(t, err)
require.Len(t, apps, 1)
assert.Equal(t, "imported-app", apps[0].Name)
// Verify env vars
envVars, _ := apps[0].GetEnvVars(context.Background())
require.Len(t, envVars, 1)
assert.Equal(t, "FOO", envVars[0].Key)
assert.Equal(t, "bar", envVars[0].Value)
// Verify labels
labels, _ := apps[0].GetLabels(context.Background())
require.Len(t, labels, 1)
assert.Equal(t, "app.name", labels[0].Key)
// Verify volumes
volumes, _ := apps[0].GetVolumes(context.Background())
require.Len(t, volumes, 1)
assert.Equal(t, "/data", volumes[0].HostPath)
assert.True(t, volumes[0].ReadOnly)
// Verify ports
ports, _ := apps[0].GetPorts(context.Background())
require.Len(t, ports, 1)
assert.Equal(t, 3000, ports[0].HostPort)
assert.Equal(t, 8080, ports[0].ContainerPort)
}
func TestHandleImportAppsSkipsDuplicateNames(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// Create an existing app with same name
createTestApp(t, testCtx, "existing-app")
backupJSON := `{
"version": 1,
"exportedAt": "2025-01-01T00:00:00Z",
"apps": [
{
"name": "existing-app",
"repoUrl": "git@example.com:user/repo.git",
"branch": "main",
"dockerfilePath": "Dockerfile",
"envVars": [],
"labels": [],
"volumes": [],
"ports": []
},
{
"name": "new-app",
"repoUrl": "git@example.com:user/new.git",
"branch": "main",
"dockerfilePath": "Dockerfile",
"envVars": [],
"labels": [],
"volumes": [],
"ports": []
}
]
}`
request := createMultipartBackupRequest(t, backupJSON)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleImportApps()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code)
assert.Contains(t, recorder.Header().Get("Location"), "skipped")
// Should have 2 apps total (existing + new)
apps, err := models.AllApps(context.Background(), testCtx.database)
require.NoError(t, err)
assert.Len(t, apps, 2)
}
func TestHandleImportAppsInvalidJSON(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := createMultipartBackupRequest(t, "not valid json")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleImportApps()
handler.ServeHTTP(recorder, request)
// Should render the page with error, not redirect
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Invalid backup file")
}
func TestHandleImportAppsUnsupportedVersion(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
backupJSON := `{"version": 99, "exportedAt": "2025-01-01T00:00:00Z", "apps": [{"name": "test"}]}`
request := createMultipartBackupRequest(t, backupJSON)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleImportApps()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Unsupported backup version")
}
func TestHandleImportAppsEmptyBundle(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
backupJSON := `{"version": 1, "exportedAt": "2025-01-01T00:00:00Z", "apps": []}`
request := createMultipartBackupRequest(t, backupJSON)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleImportApps()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "contains no apps")
}
func TestHandleImportPage(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/backup/import", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleImportPage()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Import Backup")
}
func TestExportImportRoundTrip(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createTestAppWithConfig(t, testCtx, "roundtrip-app")
// Export
exportReq := httptest.NewRequest(http.MethodGet, "/backup/export", nil)
exportRec := httptest.NewRecorder()
testCtx.handlers.HandleExportAllApps().ServeHTTP(exportRec, exportReq)
require.Equal(t, http.StatusOK, exportRec.Code)
exportedJSON := exportRec.Body.String()
// Delete the original app
apps, _ := models.AllApps(context.Background(), testCtx.database)
for _, a := range apps {
require.NoError(t, a.Delete(context.Background()))
}
// Import
importReq := createMultipartBackupRequest(t, exportedJSON)
importRec := httptest.NewRecorder()
testCtx.handlers.HandleImportApps().ServeHTTP(importRec, importReq)
assert.Equal(t, http.StatusSeeOther, importRec.Code)
// Verify the app was recreated with all config
restoredApps, _ := models.AllApps(context.Background(), testCtx.database)
require.Len(t, restoredApps, 1)
assert.Equal(t, "roundtrip-app", restoredApps[0].Name)
envVars, _ := restoredApps[0].GetEnvVars(context.Background())
assert.Len(t, envVars, 1)
labels, _ := restoredApps[0].GetLabels(context.Background())
assert.Len(t, labels, 1)
volumes, _ := restoredApps[0].GetVolumes(context.Background())
assert.Len(t, volumes, 1)
ports, _ := restoredApps[0].GetPorts(context.Background())
assert.Len(t, ports, 1)
}
// TestAPIExportApp tests the API endpoint for exporting a single app.
func TestAPIExportApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
createdApp, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{
Name: "api-export-app",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
rr := apiGet(t, tc, cookies, "/api/v1/apps/"+createdApp.ID+"/export")
assert.Equal(t, http.StatusOK, rr.Code)
var bundle app.BackupBundle
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &bundle))
assert.Equal(t, 1, bundle.Version)
require.Len(t, bundle.Apps, 1)
assert.Equal(t, "api-export-app", bundle.Apps[0].Name)
}
// TestAPIExportAppNotFound tests the API endpoint for a nonexistent app.
func TestAPIExportAppNotFound(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
rr := apiGet(t, tc, cookies, "/api/v1/apps/nonexistent/export")
assert.Equal(t, http.StatusNotFound, rr.Code)
}
// TestAPIExportAllApps tests the API endpoint for exporting all apps.
func TestAPIExportAllApps(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
_, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{
Name: "api-export-all-1",
RepoURL: "git@example.com:user/repo1.git",
})
require.NoError(t, err)
_, err = tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{
Name: "api-export-all-2",
RepoURL: "git@example.com:user/repo2.git",
})
require.NoError(t, err)
rr := apiGet(t, tc, cookies, "/api/v1/backup/export")
assert.Equal(t, http.StatusOK, rr.Code)
var bundle app.BackupBundle
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &bundle))
assert.Len(t, bundle.Apps, 2)
}
// TestAPIImportApps tests the API import endpoint.
func TestAPIImportApps(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
backupJSON := `{
"version": 1,
"exportedAt": "2025-01-01T00:00:00Z",
"apps": [{
"name": "api-imported-app",
"repoUrl": "git@example.com:user/repo.git",
"branch": "main",
"dockerfilePath": "Dockerfile",
"envVars": [],
"labels": [],
"volumes": [],
"ports": []
}]
}`
r := apiRouter(tc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", strings.NewReader(backupJSON))
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
imported, ok := resp["imported"].([]any)
require.True(t, ok)
assert.Len(t, imported, 1)
assert.Equal(t, "api-imported-app", imported[0])
}
// TestAPIImportAppsInvalidBody tests that the API rejects bad JSON.
func TestAPIImportAppsInvalidBody(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := apiRouter(tc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", strings.NewReader("not json"))
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
// TestAPIImportAppsUnsupportedVersion tests that the API rejects bad versions.
func TestAPIImportAppsUnsupportedVersion(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := apiRouter(tc)
body := `{"version": 42, "apps": [{"name": "x"}]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}

View File

@@ -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.

View File

@@ -10,16 +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"
) )
// Params contains dependencies for Handlers. // Params contains dependencies for Handlers.

View File

@@ -15,21 +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"
) )
type testContext struct { type testContext struct {
@@ -404,25 +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.NewRequest(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) {
@@ -560,242 +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, "varID": strconv.FormatInt(resourceID, 10)}
},
handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleEnvVarDelete() },
verifyFn: func(t *testing.T, tc *testContext, resourceID int64) {
t.Helper()
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.NewRequest( },
http.MethodPost, })
"/apps/"+createdApp.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
// Verify old env vars are gone and new ones exist
envVars, err := models.FindEnvVarsByAppID(
context.Background(), testCtx.database, createdApp.ID,
)
require.NoError(t, err)
assert.Len(t, envVars, 2)
keys := make(map[string]string)
for _, ev := range envVars {
keys[ev.Key] = ev.Value
}
assert.Equal(t, "new_value", keys["NEW_KEY"])
assert.Equal(t, "42", keys["ANOTHER"])
assert.Empty(t, keys["OLD_KEY"], "old env vars should be deleted")
assert.Empty(t, keys["REMOVE_ME"], "old env vars should be deleted")
}
// TestHandleEnvVarSaveAppNotFound tests that HandleEnvVarSave returns 404
// for a non-existent app.
func TestHandleEnvVarSaveAppNotFound(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
body := `[{"key":"KEY","value":"value"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/nonexistent-id/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusNotFound, recorder.Code)
}
// TestHandleEnvVarSaveEmptyKeyRejected verifies that submitting a JSON
// array containing an entry with an empty key returns 400.
func TestHandleEnvVarSaveEmptyKeyRejected(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-emptykey-app")
body := `[{"key":"VALID_KEY","value":"ok"},{"key":"","value":"bad"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
}
// TestHandleEnvVarSaveDuplicateKeyRejected verifies that when the client
// sends duplicate keys, the server rejects them with 400 Bad Request.
func TestHandleEnvVarSaveDuplicateKeyRejected(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-dedup-app")
// Send two entries with the same key — should be rejected
body := `[{"key":"FOO","value":"first"},{"key":"BAR","value":"bar"},{"key":"FOO","value":"second"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.Contains(t, recorder.Body.String(), "duplicate environment variable key: FOO")
}
// TestHandleEnvVarSaveCrossAppIsolation verifies that posting env vars
// to appA's endpoint does not affect appB's env vars (IDOR prevention).
func TestHandleEnvVarSaveCrossAppIsolation(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
appA := createTestApp(t, testCtx, "envvar-iso-appA")
appB := createTestApp(t, testCtx, "envvar-iso-appB")
// Give appB some env vars
for _, kv := range [][2]string{{"B_KEY1", "b_val1"}, {"B_KEY2", "b_val2"}} {
ev := models.NewEnvVar(testCtx.database)
ev.AppID = appB.ID
ev.Key = kv[0]
ev.Value = kv[1]
require.NoError(t, ev.Save(context.Background()))
}
// POST new env vars to appA's endpoint
body := `[{"key":"A_KEY","value":"a_val"}]`
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+appA.ID+"/env",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
// Verify appA has exactly what we sent
appAVars, err := models.FindEnvVarsByAppID(
context.Background(), testCtx.database, appA.ID,
)
require.NoError(t, err)
assert.Len(t, appAVars, 1)
assert.Equal(t, "A_KEY", appAVars[0].Key)
// Verify appB's env vars are completely untouched
appBVars, err := models.FindEnvVarsByAppID(
context.Background(), testCtx.database, appB.ID,
)
require.NoError(t, err)
assert.Len(t, appBVars, 2, "appB env vars must not be affected")
bKeys := make(map[string]string)
for _, ev := range appBVars {
bKeys[ev.Key] = ev.Value
}
assert.Equal(t, "b_val1", bKeys["B_KEY1"])
assert.Equal(t, "b_val2", bKeys["B_KEY2"])
}
// TestHandleEnvVarSaveBodySizeLimit verifies that a request body
// exceeding the 1 MB limit is rejected.
func TestHandleEnvVarSaveBodySizeLimit(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-sizelimit-app")
// Build a JSON body that exceeds 1 MB
// Each entry is ~30 bytes; 40000 entries ≈ 1.2 MB
var sb strings.Builder
sb.WriteString("[")
for i := range 40000 {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(`{"key":"K` + strconv.Itoa(i) + `","value":"val"}`)
}
sb.WriteString("]")
r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env",
strings.NewReader(sb.String()),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusBadRequest, recorder.Code,
"oversized body should be rejected with 400")
} }
// TestDeleteLabelOwnershipVerification tests that deleting a label // TestDeleteLabelOwnershipVerification tests that deleting a label
// via another app's URL path returns 404 (IDOR prevention). // via another app's URL path returns 404 (IDOR prevention).
func TestDeleteLabelOwnershipVerification(t *testing.T) { func TestDeleteLabelOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern
t.Parallel() t.Parallel()
testOwnershipVerification(t, ownedResourceTestConfig{ testOwnershipVerification(t, ownedResourceTestConfig{
@@ -911,43 +695,41 @@ 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 // TestHandleEnvVarDeleteUsesCorrectRouteParam verifies that HandleEnvVarDelete
// array deletes all existing env vars for the app. // reads the "varID" chi URL parameter (matching the route definition {varID}),
func TestHandleEnvVarSaveEmptyClears(t *testing.T) { // not a mismatched name like "envID".
func TestHandleEnvVarDeleteUsesCorrectRouteParam(t *testing.T) {
t.Parallel() t.Parallel()
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envvar-clear-app")
// Create a pre-existing env var createdApp := createTestApp(t, testCtx, "envdelete-param-app")
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 envVar := models.NewEnvVar(testCtx.database)
envVar.AppID = createdApp.ID
envVar.Key = "DELETE_ME"
envVar.Value = "gone"
require.NoError(t, envVar.Save(context.Background()))
// Use chi router with the real route pattern to test param name
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) r.Post("/apps/{id}/env-vars/{varID}/delete", testCtx.handlers.HandleEnvVarDelete())
request := httptest.NewRequest( request := httptest.NewRequest(
http.MethodPost, http.MethodPost,
"/apps/"+createdApp.ID+"/env", "/apps/"+createdApp.ID+"/env-vars/"+strconv.FormatInt(envVar.ID, 10)+"/delete",
strings.NewReader("[]"), nil,
) )
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request) r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, http.StatusSeeOther, recorder.Code)
// Verify all env vars are gone // Verify the env var was actually deleted
envVars, err := models.FindEnvVarsByAppID( found, findErr := models.FindEnvVar(context.Background(), testCtx.database, envVar.ID)
context.Background(), testCtx.database, createdApp.ID, require.NoError(t, findErr)
) assert.Nil(t, found, "env var should be deleted when using correct route param")
require.NoError(t, err)
assert.Empty(t, envVars, "all env vars should be deleted")
} }
// TestHandleVolumeAddValidatesPaths verifies that HandleVolumeAdd validates // TestHandleVolumeAddValidatesPaths verifies that HandleVolumeAdd validates

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ package handlers
import ( import (
"net/http" "net/http"
"sneak.berlin/go/upaas/templates" "git.eeqj.de/sneak/upaas/templates"
) )
const ( const (

View File

@@ -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) {

View File

@@ -6,7 +6,7 @@ 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"
) )
// maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB). // maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB).

View File

@@ -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{
"App": application,
"Events": events,
}, request)
h.renderTemplate(writer, tmpl, "webhook_events.html", data)
}
}

View File

@@ -8,10 +8,10 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"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.

View File

@@ -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.

View File

@@ -8,7 +8,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"
) )
//nolint:gosec // test credentials //nolint:gosec // test credentials

View File

@@ -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.

View File

@@ -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 {

View File

@@ -7,7 +7,7 @@ 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.

View File

@@ -8,7 +8,7 @@ import (
"strings" "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.

View File

@@ -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.
@@ -128,48 +129,13 @@ func FindEnvVarsByAppID(
return envVars, rows.Err() 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
} }

View File

@@ -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.

View File

@@ -10,11 +10,11 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 (

View File

@@ -8,7 +8,7 @@ import (
chimw "github.com/go-chi/chi/v5/middleware" chimw "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"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,7 +70,6 @@ func (s *Server) SetupRoutes() {
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy()) r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy()) r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments()) r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
r.Get("/apps/{id}/webhooks", s.handlers.HandleAppWebhookEvents())
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI()) r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload()) r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs()) r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
@@ -82,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())
@@ -98,12 +99,6 @@ func (s *Server) SetupRoutes() {
// Ports // Ports
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd()) r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete()) r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
// Backup/Restore
r.Get("/apps/{id}/export", s.handlers.HandleExportApp())
r.Get("/backup/export", s.handlers.HandleExportAllApps())
r.Get("/backup/import", s.handlers.HandleImportPage())
r.Post("/backup/import", s.handlers.HandleImportApps())
}) })
}) })
@@ -121,11 +116,6 @@ func (s *Server) SetupRoutes() {
r.Get("/apps", s.handlers.HandleAPIListApps()) r.Get("/apps", s.handlers.HandleAPIListApps())
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp()) r.Get("/apps/{id}", s.handlers.HandleAPIGetApp())
r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments()) r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments())
// Backup/Restore API
r.Get("/apps/{id}/export", s.handlers.HandleAPIExportApp())
r.Get("/backup/export", s.handlers.HandleAPIExportAllApps())
r.Post("/backup/import", s.handlers.HandleAPIImportApps())
}) })
}) })

View File

@@ -12,11 +12,11 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"go.uber.org/fx" "go.uber.org/fx"
"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.

View File

@@ -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.

View File

@@ -8,12 +8,12 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"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"
) )
func setupTestService(t *testing.T) (*app.Service, func()) { func setupTestService(t *testing.T) (*app.Service, func()) {

View File

@@ -1,391 +0,0 @@
package app
import (
"context"
"fmt"
"time"
"sneak.berlin/go/upaas/internal/models"
)
// BackupEnvVar represents an environment variable in a backup.
type BackupEnvVar struct {
Key string `json:"key"`
Value string `json:"value"`
}
// BackupLabel represents a Docker label in a backup.
type BackupLabel struct {
Key string `json:"key"`
Value string `json:"value"`
}
// BackupVolume represents a volume mount in a backup.
type BackupVolume struct {
HostPath string `json:"hostPath"`
ContainerPath string `json:"containerPath"`
ReadOnly bool `json:"readOnly"`
}
// BackupPort represents a port mapping in a backup.
type BackupPort struct {
HostPort int `json:"hostPort"`
ContainerPort int `json:"containerPort"`
Protocol string `json:"protocol"`
}
// Backup represents the exported configuration of a single app.
type Backup struct {
Name string `json:"name"`
RepoURL string `json:"repoUrl"`
Branch string `json:"branch"`
DockerfilePath string `json:"dockerfilePath"`
DockerNetwork string `json:"dockerNetwork,omitempty"`
NtfyTopic string `json:"ntfyTopic,omitempty"`
SlackWebhook string `json:"slackWebhook,omitempty"`
EnvVars []BackupEnvVar `json:"envVars"`
Labels []BackupLabel `json:"labels"`
Volumes []BackupVolume `json:"volumes"`
Ports []BackupPort `json:"ports"`
}
// BackupBundle represents a complete backup of one or more apps.
type BackupBundle struct {
Version int `json:"version"`
ExportedAt string `json:"exportedAt"`
Apps []Backup `json:"apps"`
}
// backupVersion is the current backup format version.
const backupVersion = 1
// ExportApp exports a single app's configuration as a BackupBundle.
func (svc *Service) ExportApp(
ctx context.Context,
application *models.App,
) (*BackupBundle, error) {
appBackup, err := svc.buildAppBackup(ctx, application)
if err != nil {
return nil, err
}
return &BackupBundle{
Version: backupVersion,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Apps: []Backup{appBackup},
}, nil
}
// ExportAllApps exports all app configurations as a BackupBundle.
func (svc *Service) ExportAllApps(ctx context.Context) (*BackupBundle, error) {
apps, err := models.AllApps(ctx, svc.db)
if err != nil {
return nil, fmt.Errorf("listing apps for export: %w", err)
}
backups := make([]Backup, 0, len(apps))
for _, application := range apps {
appBackup, buildErr := svc.buildAppBackup(ctx, application)
if buildErr != nil {
return nil, buildErr
}
backups = append(backups, appBackup)
}
return &BackupBundle{
Version: backupVersion,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Apps: backups,
}, nil
}
// ImportApps imports app configurations from a BackupBundle.
// It creates new apps (with fresh IDs, SSH keys, and webhook secrets)
// and populates their env vars, labels, volumes, and ports.
// Apps whose names conflict with existing apps are skipped and reported.
func (svc *Service) ImportApps(
ctx context.Context,
bundle *BackupBundle,
) ([]string, []string, error) {
// Build a set of existing app names for conflict detection
existingApps, listErr := models.AllApps(ctx, svc.db)
if listErr != nil {
return nil, nil, fmt.Errorf("listing existing apps: %w", listErr)
}
existingNames := make(map[string]bool, len(existingApps))
for _, a := range existingApps {
existingNames[a.Name] = true
}
var imported, skipped []string
for _, ab := range bundle.Apps {
if existingNames[ab.Name] {
skipped = append(skipped, ab.Name)
continue
}
importErr := svc.importSingleApp(ctx, ab)
if importErr != nil {
return imported, skipped, fmt.Errorf(
"importing app %q: %w", ab.Name, importErr,
)
}
imported = append(imported, ab.Name)
}
return imported, skipped, nil
}
// importSingleApp creates a single app from backup data.
func (svc *Service) importSingleApp(
ctx context.Context,
ab Backup,
) error {
createdApp, createErr := svc.CreateApp(ctx, CreateAppInput{
Name: ab.Name,
RepoURL: ab.RepoURL,
Branch: ab.Branch,
DockerfilePath: ab.DockerfilePath,
DockerNetwork: ab.DockerNetwork,
NtfyTopic: ab.NtfyTopic,
SlackWebhook: ab.SlackWebhook,
})
if createErr != nil {
return fmt.Errorf("creating app: %w", createErr)
}
envErr := svc.importEnvVars(ctx, createdApp.ID, ab.EnvVars)
if envErr != nil {
return envErr
}
labelErr := svc.importLabels(ctx, createdApp.ID, ab.Labels)
if labelErr != nil {
return labelErr
}
volErr := svc.importVolumes(ctx, createdApp.ID, ab.Volumes)
if volErr != nil {
return volErr
}
portErr := svc.importPorts(ctx, createdApp.ID, ab.Ports)
if portErr != nil {
return portErr
}
svc.log.Info("app imported from backup",
"id", createdApp.ID, "name", createdApp.Name)
return nil
}
// importEnvVars adds env vars from backup to an app.
func (svc *Service) importEnvVars(
ctx context.Context,
appID string,
envVars []BackupEnvVar,
) error {
for _, ev := range envVars {
addErr := svc.AddEnvVar(ctx, appID, ev.Key, ev.Value)
if addErr != nil {
return fmt.Errorf("adding env var %q: %w", ev.Key, addErr)
}
}
return nil
}
// importLabels adds labels from backup to an app.
func (svc *Service) importLabels(
ctx context.Context,
appID string,
labels []BackupLabel,
) error {
for _, l := range labels {
addErr := svc.AddLabel(ctx, appID, l.Key, l.Value)
if addErr != nil {
return fmt.Errorf("adding label %q: %w", l.Key, addErr)
}
}
return nil
}
// importVolumes adds volumes from backup to an app.
func (svc *Service) importVolumes(
ctx context.Context,
appID string,
volumes []BackupVolume,
) error {
for _, v := range volumes {
addErr := svc.AddVolume(ctx, appID, v.HostPath, v.ContainerPath, v.ReadOnly)
if addErr != nil {
return fmt.Errorf("adding volume %q: %w", v.ContainerPath, addErr)
}
}
return nil
}
// importPorts adds ports from backup to an app.
func (svc *Service) importPorts(
ctx context.Context,
appID string,
ports []BackupPort,
) error {
for _, p := range ports {
port := models.NewPort(svc.db)
port.AppID = appID
port.HostPort = p.HostPort
port.ContainerPort = p.ContainerPort
port.Protocol = models.PortProtocol(p.Protocol)
if port.Protocol == "" {
port.Protocol = models.PortProtocolTCP
}
saveErr := port.Save(ctx)
if saveErr != nil {
return fmt.Errorf("adding port %d: %w", p.HostPort, saveErr)
}
}
return nil
}
// buildAppBackup collects all configuration for a single app into a Backup.
func (svc *Service) buildAppBackup(
ctx context.Context,
application *models.App,
) (Backup, error) {
envVars, labels, volumes, ports, err := svc.fetchAppResources(ctx, application)
if err != nil {
return Backup{}, err
}
backup := Backup{
Name: application.Name,
RepoURL: application.RepoURL,
Branch: application.Branch,
DockerfilePath: application.DockerfilePath,
EnvVars: convertEnvVars(envVars),
Labels: convertLabels(labels),
Volumes: convertVolumes(volumes),
Ports: convertPorts(ports),
}
if application.DockerNetwork.Valid {
backup.DockerNetwork = application.DockerNetwork.String
}
if application.NtfyTopic.Valid {
backup.NtfyTopic = application.NtfyTopic.String
}
if application.SlackWebhook.Valid {
backup.SlackWebhook = application.SlackWebhook.String
}
return backup, nil
}
// fetchAppResources retrieves all sub-resources for an app.
func (svc *Service) fetchAppResources(
ctx context.Context,
application *models.App,
) ([]*models.EnvVar, []*models.Label, []*models.Volume, []*models.Port, error) {
envVars, err := application.GetEnvVars(ctx)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf(
"getting env vars for %q: %w", application.Name, err,
)
}
labels, err := application.GetLabels(ctx)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf(
"getting labels for %q: %w", application.Name, err,
)
}
volumes, err := application.GetVolumes(ctx)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf(
"getting volumes for %q: %w", application.Name, err,
)
}
ports, err := application.GetPorts(ctx)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf(
"getting ports for %q: %w", application.Name, err,
)
}
return envVars, labels, volumes, ports, nil
}
// convertEnvVars converts model env vars to backup format.
func convertEnvVars(envVars []*models.EnvVar) []BackupEnvVar {
result := make([]BackupEnvVar, 0, len(envVars))
for _, ev := range envVars {
result = append(result, BackupEnvVar{
Key: ev.Key,
Value: ev.Value,
})
}
return result
}
// convertLabels converts model labels to backup format.
func convertLabels(labels []*models.Label) []BackupLabel {
result := make([]BackupLabel, 0, len(labels))
for _, l := range labels {
result = append(result, BackupLabel{
Key: l.Key,
Value: l.Value,
})
}
return result
}
// convertVolumes converts model volumes to backup format.
func convertVolumes(volumes []*models.Volume) []BackupVolume {
result := make([]BackupVolume, 0, len(volumes))
for _, v := range volumes {
result = append(result, BackupVolume{
HostPath: v.HostPath,
ContainerPath: v.ContainerPath,
ReadOnly: v.ReadOnly,
})
}
return result
}
// convertPorts converts model ports to backup format.
func convertPorts(ports []*models.Port) []BackupPort {
result := make([]BackupPort, 0, len(ports))
for _, p := range ports {
result = append(result, BackupPort{
HostPort: p.HostPort,
ContainerPort: p.ContainerPort,
Protocol: string(p.Protocol),
})
}
return result
}

View File

@@ -1,379 +0,0 @@
package app_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app"
)
// backupTestContext bundles test dependencies for backup tests.
type backupTestContext struct {
svc *app.Service
db *database.Database
}
func setupBackupTest(t *testing.T) *backupTestContext {
t.Helper()
tmpDir := t.TempDir()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalsInst, err := globals.New(fx.Lifecycle(nil))
require.NoError(t, err)
loggerInst, err := logger.New(
fx.Lifecycle(nil),
logger.Params{Globals: globalsInst},
)
require.NoError(t, err)
cfg := &config.Config{
Port: 8080,
DataDir: tmpDir,
SessionSecret: "test-secret-key-at-least-32-chars",
}
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{
Logger: loggerInst,
Config: cfg,
})
require.NoError(t, err)
svc, err := app.New(fx.Lifecycle(nil), app.ServiceParams{
Logger: loggerInst,
Database: dbInst,
})
require.NoError(t, err)
return &backupTestContext{svc: svc, db: dbInst}
}
// createAppWithFullConfig creates an app with env vars, labels, volumes, and ports.
func createAppWithFullConfig(
t *testing.T,
btc *backupTestContext,
name string,
) *models.App {
t.Helper()
createdApp, err := btc.svc.CreateApp(context.Background(), app.CreateAppInput{
Name: name,
RepoURL: "git@example.com:user/" + name + ".git",
Branch: "develop",
NtfyTopic: "https://ntfy.sh/" + name,
DockerNetwork: "test-network",
})
require.NoError(t, err)
require.NoError(t, btc.svc.AddEnvVar(
context.Background(), createdApp.ID, "DB_HOST", "localhost",
))
require.NoError(t, btc.svc.AddEnvVar(
context.Background(), createdApp.ID, "DB_PORT", "5432",
))
require.NoError(t, btc.svc.AddLabel(
context.Background(), createdApp.ID, "traefik.enable", "true",
))
require.NoError(t, btc.svc.AddVolume(
context.Background(), createdApp.ID, "/data", "/app/data", false,
))
port := models.NewPort(btc.db)
port.AppID = createdApp.ID
port.HostPort = 9090
port.ContainerPort = 8080
port.Protocol = models.PortProtocolTCP
require.NoError(t, port.Save(context.Background()))
return createdApp
}
// createAppWithConfigPort creates an app like createAppWithFullConfig but with
// a custom host port to avoid UNIQUE constraint collisions.
func createAppWithConfigPort(
t *testing.T,
btc *backupTestContext,
name string,
hostPort int,
) *models.App {
t.Helper()
createdApp, err := btc.svc.CreateApp(context.Background(), app.CreateAppInput{
Name: name,
RepoURL: "git@example.com:user/" + name + ".git",
Branch: "develop",
NtfyTopic: "https://ntfy.sh/" + name,
DockerNetwork: "test-network",
})
require.NoError(t, err)
require.NoError(t, btc.svc.AddEnvVar(
context.Background(), createdApp.ID, "DB_HOST", "localhost",
))
require.NoError(t, btc.svc.AddLabel(
context.Background(), createdApp.ID, "traefik.enable", "true",
))
require.NoError(t, btc.svc.AddVolume(
context.Background(), createdApp.ID, "/data2", "/app/data2", false,
))
port := models.NewPort(btc.db)
port.AppID = createdApp.ID
port.HostPort = hostPort
port.ContainerPort = 8080
port.Protocol = models.PortProtocolTCP
require.NoError(t, port.Save(context.Background()))
return createdApp
}
func TestExportApp(t *testing.T) {
t.Parallel()
btc := setupBackupTest(t)
createdApp := createAppWithFullConfig(t, btc, "export-svc-test")
bundle, err := btc.svc.ExportApp(context.Background(), createdApp)
require.NoError(t, err)
assert.Equal(t, 1, bundle.Version)
assert.NotEmpty(t, bundle.ExportedAt)
require.Len(t, bundle.Apps, 1)
ab := bundle.Apps[0]
assert.Equal(t, "export-svc-test", ab.Name)
assert.Equal(t, "develop", ab.Branch)
assert.Equal(t, "test-network", ab.DockerNetwork)
assert.Equal(t, "https://ntfy.sh/export-svc-test", ab.NtfyTopic)
assert.Len(t, ab.EnvVars, 2)
assert.Len(t, ab.Labels, 1)
assert.Len(t, ab.Volumes, 1)
assert.Len(t, ab.Ports, 1)
assert.Equal(t, 9090, ab.Ports[0].HostPort)
assert.Equal(t, 8080, ab.Ports[0].ContainerPort)
assert.Equal(t, "tcp", ab.Ports[0].Protocol)
}
func TestExportAllApps(t *testing.T) {
t.Parallel()
btc := setupBackupTest(t)
createAppWithFullConfig(t, btc, "export-all-1")
createAppWithConfigPort(t, btc, "export-all-2", 9091)
bundle, err := btc.svc.ExportAllApps(context.Background())
require.NoError(t, err)
assert.Equal(t, 1, bundle.Version)
assert.Len(t, bundle.Apps, 2)
}
func TestExportAllAppsEmpty(t *testing.T) {
t.Parallel()
btc := setupBackupTest(t)
bundle, err := btc.svc.ExportAllApps(context.Background())
require.NoError(t, err)
assert.Empty(t, bundle.Apps)
}
func TestImportApps(t *testing.T) {
t.Parallel()
btc := setupBackupTest(t)
bundle := &app.BackupBundle{
Version: 1,
ExportedAt: "2025-01-01T00:00:00Z",
Apps: []app.Backup{
{
Name: "imported-test",
RepoURL: "git@example.com:user/imported.git",
Branch: "main",
DockerfilePath: "Dockerfile",
DockerNetwork: "my-network",
EnvVars: []app.BackupEnvVar{
{Key: "FOO", Value: "bar"},
},
Labels: []app.BackupLabel{
{Key: "app", Value: "test"},
},
Volumes: []app.BackupVolume{
{HostPath: "/host", ContainerPath: "/container", ReadOnly: true},
},
Ports: []app.BackupPort{
{HostPort: 3000, ContainerPort: 8080, Protocol: "tcp"},
},
},
},
}
imported, skipped, err := btc.svc.ImportApps(context.Background(), bundle)
require.NoError(t, err)
assert.Equal(t, []string{"imported-test"}, imported)
assert.Empty(t, skipped)
// Verify the app was created
apps, _ := btc.svc.ListApps(context.Background())
require.Len(t, apps, 1)
assert.Equal(t, "imported-test", apps[0].Name)
assert.True(t, apps[0].DockerNetwork.Valid)
assert.Equal(t, "my-network", apps[0].DockerNetwork.String)
// Has fresh secrets
assert.NotEmpty(t, apps[0].WebhookSecret)
assert.NotEmpty(t, apps[0].SSHPublicKey)
// Verify sub-resources
envVars, _ := apps[0].GetEnvVars(context.Background())
assert.Len(t, envVars, 1)
labels, _ := apps[0].GetLabels(context.Background())
assert.Len(t, labels, 1)
volumes, _ := apps[0].GetVolumes(context.Background())
assert.Len(t, volumes, 1)
assert.True(t, volumes[0].ReadOnly)
ports, _ := apps[0].GetPorts(context.Background())
assert.Len(t, ports, 1)
assert.Equal(t, 3000, ports[0].HostPort)
}
func TestImportAppsSkipsDuplicates(t *testing.T) {
t.Parallel()
btc := setupBackupTest(t)
// Create existing app
_, err := btc.svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "existing",
RepoURL: "git@example.com:user/existing.git",
})
require.NoError(t, err)
bundle := &app.BackupBundle{
Version: 1,
ExportedAt: "2025-01-01T00:00:00Z",
Apps: []app.Backup{
{
Name: "existing",
RepoURL: "git@example.com:user/existing.git",
Branch: "main",
DockerfilePath: "Dockerfile",
EnvVars: []app.BackupEnvVar{},
Labels: []app.BackupLabel{},
Volumes: []app.BackupVolume{},
Ports: []app.BackupPort{},
},
{
Name: "brand-new",
RepoURL: "git@example.com:user/new.git",
Branch: "main",
DockerfilePath: "Dockerfile",
EnvVars: []app.BackupEnvVar{},
Labels: []app.BackupLabel{},
Volumes: []app.BackupVolume{},
Ports: []app.BackupPort{},
},
},
}
imported, skipped, err := btc.svc.ImportApps(context.Background(), bundle)
require.NoError(t, err)
assert.Equal(t, []string{"brand-new"}, imported)
assert.Equal(t, []string{"existing"}, skipped)
}
func TestImportAppsPortDefaultProtocol(t *testing.T) {
t.Parallel()
btc := setupBackupTest(t)
bundle := &app.BackupBundle{
Version: 1,
ExportedAt: "2025-01-01T00:00:00Z",
Apps: []app.Backup{
{
Name: "port-default-test",
RepoURL: "git@example.com:user/repo.git",
Branch: "main",
DockerfilePath: "Dockerfile",
EnvVars: []app.BackupEnvVar{},
Labels: []app.BackupLabel{},
Volumes: []app.BackupVolume{},
Ports: []app.BackupPort{
{HostPort: 80, ContainerPort: 80, Protocol: ""},
},
},
},
}
imported, _, err := btc.svc.ImportApps(context.Background(), bundle)
require.NoError(t, err)
assert.Len(t, imported, 1)
apps, _ := btc.svc.ListApps(context.Background())
ports, _ := apps[0].GetPorts(context.Background())
require.Len(t, ports, 1)
assert.Equal(t, models.PortProtocolTCP, ports[0].Protocol)
}
func TestExportImportRoundTripService(t *testing.T) {
t.Parallel()
btc := setupBackupTest(t)
createAppWithFullConfig(t, btc, "roundtrip-svc")
// Export
bundle, err := btc.svc.ExportAllApps(context.Background())
require.NoError(t, err)
require.Len(t, bundle.Apps, 1)
// Delete original
apps, _ := btc.svc.ListApps(context.Background())
for _, a := range apps {
require.NoError(t, btc.svc.DeleteApp(context.Background(), a))
}
// Import
imported, skipped, err := btc.svc.ImportApps(context.Background(), bundle)
require.NoError(t, err)
assert.Len(t, imported, 1)
assert.Empty(t, skipped)
// Verify round-trip fidelity
restored, _ := btc.svc.ListApps(context.Background())
require.Len(t, restored, 1)
assert.Equal(t, "roundtrip-svc", restored[0].Name)
assert.Equal(t, "develop", restored[0].Branch)
assert.Equal(t, "test-network", restored[0].DockerNetwork.String)
envVars, _ := restored[0].GetEnvVars(context.Background())
assert.Len(t, envVars, 2)
labels, _ := restored[0].GetLabels(context.Background())
assert.Len(t, labels, 1)
volumes, _ := restored[0].GetVolumes(context.Background())
assert.Len(t, volumes, 1)
ports, _ := restored[0].GetPorts(context.Background())
assert.Len(t, ports, 1)
}

View File

@@ -15,10 +15,10 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
"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 (

View File

@@ -12,11 +12,11 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"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()) {

View File

@@ -17,12 +17,12 @@ 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/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.

View File

@@ -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) {

View File

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

View File

@@ -6,10 +6,10 @@ import (
"os" "os"
"testing" "testing"
"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/models" "git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
) )
func TestBuildContainerOptionsUsesImageID(t *testing.T) { func TestBuildContainerOptionsUsesImageID(t *testing.T) {

View File

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

View File

@@ -15,8 +15,8 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"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.

View File

@@ -10,11 +10,11 @@ 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/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
) )
// ServiceParams contains dependencies for Service. // ServiceParams contains dependencies for Service.

View File

@@ -12,15 +12,15 @@ 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/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/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/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"
) )
type testDeps struct { type testDeps struct {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,10 +77,7 @@
<!-- Webhook URL --> <!-- Webhook URL -->
<div class="card p-6 mb-6"> <div class="card p-6 mb-6">
<div class="flex items-center justify-between mb-4"> <h2 class="section-title mb-4">Webhook URL</h2>
<h2 class="section-title">Webhook URL</h2>
<a href="/apps/{{.App.ID}}/webhooks" class="text-primary-600 hover:text-primary-800 text-sm">Event History</a>
</div>
<p class="text-sm text-gray-500 mb-3">Add this URL as a push webhook in your Gitea repository:</p> <p class="text-sm text-gray-500 mb-3">Add this URL as a push webhook in your Gitea repository:</p>
<div class="copy-field" x-data="copyButton('webhook-url')"> <div class="copy-field" x-data="copyButton('webhook-url')">
<code id="webhook-url" class="copy-field-value text-xs">{{.WebhookURL}}</code> <code id="webhook-url" class="copy-field-value text-xs">{{.WebhookURL}}</code>
@@ -101,10 +98,9 @@
</div> </div>
<!-- Environment Variables --> <!-- Environment Variables -->
<div class="card p-6 mb-6" x-data="envVarEditor('{{.App.ID}}')"> <div class="card p-6 mb-6">
<h2 class="section-title mb-4">Environment Variables</h2> <h2 class="section-title mb-4">Environment Variables</h2>
{{range .EnvVars}}<span class="env-init hidden" data-key="{{.Key}}" data-value="{{.Value}}"></span>{{end}} {{if .EnvVars}}
<template x-if="vars.length > 0">
<div class="overflow-x-auto mb-4"> <div class="overflow-x-auto mb-4">
<table class="table"> <table class="table">
<thead class="table-header"> <thead class="table-header">
@@ -115,43 +111,47 @@
</tr> </tr>
</thead> </thead>
<tbody class="table-body"> <tbody class="table-body">
<template x-for="(env, idx) in vars" :key="idx"> {{range .EnvVars}}
<tr> <tr x-data="{ editing: false }">
<template x-if="editIdx !== idx"> <template x-if="!editing">
<td class="font-mono font-medium" x-text="env.key"></td> <td class="font-mono font-medium">{{.Key}}</td>
</template> </template>
<template x-if="editIdx !== idx"> <template x-if="!editing">
<td class="font-mono text-gray-500" x-text="env.value"></td> <td class="font-mono text-gray-500">{{.Value}}</td>
</template> </template>
<template x-if="editIdx !== idx"> <template x-if="!editing">
<td class="text-right"> <td class="text-right">
<button @click="startEdit(idx)" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button> <button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
<button @click="removeVar(idx)" class="text-error-500 hover:text-error-700 text-sm">Delete</button> <form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
{{ $.CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form>
</td> </td>
</template> </template>
<template x-if="editIdx === idx"> <template x-if="editing">
<td colspan="3"> <td colspan="3">
<form @submit.prevent="saveEdit(idx)" class="flex gap-2 items-center"> <form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/edit" class="flex gap-2 items-center">
<input type="text" x-model="editKey" required class="input flex-1 font-mono text-sm"> {{ $.CSRFField }}
<input type="text" x-model="editVal" required class="input flex-1 font-mono text-sm"> <input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary text-sm">Save</button> <button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editIdx = -1" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button> <button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form> </form>
<p class="text-xs text-amber-600 mt-1">⚠ Container restart needed after env var changes.</p> <p class="text-xs text-amber-600 mt-1">⚠ Container restart needed after env var changes.</p>
</td> </td>
</template> </template>
</tr> </tr>
</template> {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</template> {{end}}
<form @submit.prevent="addVar($refs.newKey, $refs.newVal)" class="flex flex-col sm:flex-row gap-2"> <form method="POST" action="/apps/{{.App.ID}}/env" class="flex flex-col sm:flex-row gap-2">
<input x-ref="newKey" type="text" placeholder="KEY" required class="input flex-1 font-mono text-sm"> {{ .CSRFField }}
<input x-ref="newVal" type="text" placeholder="value" required class="input flex-1 font-mono text-sm"> <input type="text" name="key" placeholder="KEY" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" placeholder="value" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary">Add</button> <button type="submit" class="btn-primary">Add</button>
</form> </form>
<div class="hidden">{{ .CSRFField }}</div>
</div> </div>
<!-- Labels --> <!-- Labels -->
@@ -432,18 +432,6 @@
</div> </div>
</div> </div>
<!-- Backup -->
<div class="card p-6 mb-6">
<h2 class="section-title mb-4">Backup</h2>
<p class="text-sm text-gray-500 mb-3">Export this app's configuration (settings, env vars, labels, volumes, ports) as a JSON file for backup or migration.</p>
<a href="/apps/{{.App.ID}}/export" class="btn-secondary">
<svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Export Config
</a>
</div>
<!-- Danger Zone --> <!-- Danger Zone -->
<div class="card border-2 border-error-500/20 bg-error-50/50 p-6"> <div class="card border-2 border-error-500/20 bg-error-50/50 p-6">
<h2 class="text-lg font-medium text-error-700 mb-4">Danger Zone</h2> <h2 class="text-lg font-medium text-error-700 mb-4">Danger Zone</h2>

View File

@@ -1,62 +0,0 @@
{{template "base" .}}
{{define "title"}}Import Backup - µPaaS{{end}}
{{define "content"}}
{{template "nav" .}}
<main class="max-w-4xl mx-auto px-4 py-8">
<div class="mb-6">
<a href="/" class="text-primary-600 hover:text-primary-800 inline-flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Dashboard
</a>
</div>
{{template "alert-success" .}}
{{template "alert-error" .}}
<h1 class="text-2xl font-medium text-gray-900 mb-6">Import Backup</h1>
<div class="card p-6 mb-6">
<h2 class="section-title mb-4">Restore from Backup File</h2>
<p class="text-sm text-gray-500 mb-4">
Upload a previously exported µPaaS backup file (JSON) to restore app configurations.
New apps will be created with fresh SSH keys and webhook secrets.
Apps whose names already exist will be skipped.
</p>
<form method="POST" action="/backup/import" enctype="multipart/form-data">
{{ .CSRFField }}
<div class="mb-4">
<label for="backup_file" class="form-label">Backup File</label>
<input type="file" id="backup_file" name="backup_file" accept=".json,application/json"
class="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded file:border-0
file:text-sm file:font-medium
file:bg-primary-50 file:text-primary-700
hover:file:bg-primary-100
cursor-pointer">
</div>
<button type="submit" class="btn-primary">Import</button>
</form>
</div>
<div class="card p-6">
<h2 class="section-title mb-4">Export All Apps</h2>
<p class="text-sm text-gray-500 mb-4">
Download a backup of all app configurations. This includes app settings,
environment variables, labels, volumes, and port mappings.
Secrets (SSH keys, webhook tokens) are not included — they are regenerated on import.
</p>
<a href="/backup/export" class="btn-secondary">
<svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Export All Apps
</a>
</div>
</main>
{{end}}

View File

@@ -11,13 +11,6 @@
<div class="section-header"> <div class="section-header">
<h1 class="text-2xl font-medium text-gray-900">Applications</h1> <h1 class="text-2xl font-medium text-gray-900">Applications</h1>
<div class="flex gap-3">
<a href="/backup/import" class="btn-secondary">
<svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
Backup / Restore
</a>
<a href="/apps/new" class="btn-primary"> <a href="/apps/new" class="btn-primary">
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
@@ -25,7 +18,6 @@
New App New App
</a> </a>
</div> </div>
</div>
{{if .AppStats}} {{if .AppStats}}
<div class="card overflow-hidden"> <div class="card overflow-hidden">
@@ -77,7 +69,7 @@
<a href="/apps/{{.App.ID}}" class="btn-text text-sm py-1 px-2">View</a> <a href="/apps/{{.App.ID}}" class="btn-text text-sm py-1 px-2">View</a>
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary text-sm py-1 px-2">Edit</a> <a href="/apps/{{.App.ID}}/edit" class="btn-secondary text-sm py-1 px-2">Edit</a>
<form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline"> <form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline">
{{ $.CSRFField }} {{ .CSRFField }}
<button type="submit" class="btn-success text-sm py-1 px-2">Deploy</button> <button type="submit" class="btn-success text-sm py-1 px-2">Deploy</button>
</form> </form>
</div> </div>

View File

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

View File

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