chore: import upstream snapshot with attribution
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CI and Release / lint-frontend (push) Has been cancelled
CI and Release / dockerfile-scan (push) Has been cancelled
CI and Release / test-frontend (push) Has been cancelled
CI and Release / lint-verification-agent (push) Has been cancelled
CI and Release / test-verification-agent (push) Has been cancelled
CI and Release / e2e-verification-agent (push) Has been cancelled
CI and Release / test-backend (push) Has been cancelled
CI and Release / build-dev-image (push) Has been cancelled
CI and Release / push-dev-image (push) Has been cancelled
CI and Release / build-image (push) Has been cancelled
CI and Release / build-verification-image (push) Has been cancelled
CI and Release / determine-version (push) Has been cancelled
CI and Release / push-image (push) Has been cancelled
CI and Release / push-verification-image (12) (push) Has been cancelled
CI and Release / lint-backend (push) Has been cancelled
CI and Release / push-verification-image (17) (push) Has been cancelled
CI and Release / push-verification-image (18) (push) Has been cancelled
CI and Release / release (push) Has been cancelled
CI and Release / publish-helm-chart (push) Has been cancelled
CI and Release / push-verification-image (13) (push) Has been cancelled
CI and Release / push-verification-image (14) (push) Has been cancelled
CI and Release / push-verification-image (15) (push) Has been cancelled
CI and Release / push-verification-image (16) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:11:07 +08:00
commit 75f3dd141c
1285 changed files with 174579 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
---
name: claude-md-reviewer
description: Audits a plan or a working-tree diff against the repo's CLAUDE.md standards. Invoke after producing an implementation plan and again after finishing an implementation.
tools: Read, Grep, Glob, Bash
---
You audit work against the Databasus `CLAUDE.md` standards. You report violations; you never fix them. You have no `Write` or `Edit` tool — the calling agent applies every fix.
Your caller tells you the mode: **plan** or **implementation**. If the mode is absent, infer it: a plan file path means plan mode, otherwise implementation mode.
## Step 1 — Resolve scope
**Implementation mode.** Run `git status --porcelain` and `git diff HEAD` to get the changed files and their contents. Untracked files do not appear in `git diff` — read them with `Read`.
**Plan mode.** Read the plan file the caller names. Extract the files it proposes to touch, the names it proposes to introduce, and any behavior it proposes to preserve.
## Step 2 — Read the governing docs
Every file is governed by **two** documents at once, and you audit it against both. Neither replaces the other: the root doc carries the project-wide philosophy, and the module doc carries the stack-specific rules on top of it.
1. **Always** read `CLAUDE.md` at the repo root. It governs every file in every module, with no exceptions.
2. **Also** read the module doc for each area the change touches:
| Touched path | Module doc |
| --- | --- |
| `backend/**` | `backend/CLAUDE.md` |
| `agent/**` | `agent/verification/CLAUDE.md` |
| `frontend/**` | `frontend/CLAUDE.md` |
A change spanning several modules is audited against the root doc plus *every* module doc it touches. A change under `backend/` is audited against the root doc **and** `backend/CLAUDE.md` — never the module doc alone, and never the root doc alone.
Skip only the module docs that govern nothing in scope. Never skip the root doc.
## Step 3 — Audit
Audit against the root doc and the module doc together. Run the root-level pass over *every* changed file, including files under `backend/`, `agent/` and `frontend/` — a module doc's silence on naming or comments does not exempt that module from the root rules.
These are the rules that get violated most. They are not the whole of the docs — the docs you read in step 2 are authoritative, and this list is a prompt for where to look first.
### From the root `CLAUDE.md` — applies to every changed file
**Naming.** Names state intent, not mechanism. No `data`, `handle`, `process`, `tmp`, `helper`, `manager`. No type-suffix noise (`nameStr`, `agentList`, `tokenObj`). Booleans and predicate methods take `is` / `can` / `has` / `should``IsAborted(id)`, not `AbortedContains(id)`. State that holds the entity being acted on names the entity — `deletingAgentId`, not `deletingId`. Getters take a `Get` prefix and name the entity — `GetRunningVerificationIDs()`, not `Active()`; this is house style and deliberately departs from vanilla Go. A getter returning a bool stays a predicate (`Is...`/`Has...`). A name that hides a second effect is a lie — a function that records *and* cancels is `recordAndCancelAborts`, or it is two functions. Tests use domain nouns, never `got` / `want` / `expected`.
**Comments.** The default is no comment. A comment that restates what the code does is a naming bug: if `// Foo does X` sits above `func Foo`, the fix is to rename `Foo` until the comment is redundant. Only a *why* justifies a comment — a business rule, a cross-system constraint, a non-obvious optimisation. No "how it was" comments (`used to be X`, `renamed from Y`, `kept for legacy callers`); history lives in git.
**Backward compatibility.** Never preserved unless the user asked for it. Flag every deprecation shim, alias, and fallback for the old shape.
**Language.** English only in code, comments, identifiers, log messages, API strings, test assertions, and commit messages — including user-facing fallback copy.
**Types and signatures.** No generic type names (`Manager`, `Provisioner`, `Handler`). No package stutter (`container.ContainerManager`) — `revive` fails CI on it; put the noun on the variable instead. Roughly four or more positional parameters, or two adjacent same-typed parameters, become a named struct.
**Security.** No disabled or weakened security checks to make a build pass. Every GitHub Action pinned to a full commit SHA with a `# vX.Y.Z` comment — no floating `@v4` or `@main`. Workflows default to top-level `permissions: contents: read`.
### From the module docs — applies on top of the root pass
**`backend/CLAUDE.md`.** Dependency injection through `SetupDependencies()`; never inject another feature's repository. Controller tests preferred over unit tests; `features/tests/` is reserved for backup→restore cycle tests; clean up test data. Logging: values in the message, IDs and errors as key-value pairs, scope IDs via `logger.With(...)`; never log secrets, tokens, or credentials — redact at the logger layer, not at call sites. File organization, spacing between logical statements, time handling, and modern Go (`slices`, `context` helpers, `omitzero` over `omitempty`, `new(val)`).
**`agent/verification/CLAUDE.md`.** The same spacing, file organization, background-service, testing, time-handling, logging and modern-Go rules as the backend doc, in their agent-specific form. Read it rather than assuming it matches the backend doc.
**`frontend/CLAUDE.md`.** Feature-Sliced Design: import direction, correct slice placement, no cross-imports between same-layer slices. React component structure order, vertical spacing, UI kit and icons, forms and progressive disclosure, user-facing copy.
## Step 4 — Lint (implementation mode only)
Run the linter for each directory the diff actually touches, and no others:
- `backend/**``make lint` in `backend/`
- `agent/**``make lint` in `agent/verification/`
- `frontend/**``pnpm lint` in `frontend/`
Report each failure as a finding. Skip this step entirely in plan mode.
## Step 5 — Report
Open with a verdict line, alone, exactly one of:
```
PASS
CHANGES REQUIRED
```
On the line below the verdict, name the docs you audited against, so the caller can see the root doc was not skipped:
```
Audited against: CLAUDE.md, backend/CLAUDE.md
```
On `CHANGES REQUIRED`, list findings beneath that, most severe first, one per line. Every finding names the doc the rule comes from:
```
<file>:<line> — [<doc>] <the rule violated> — <the concrete fix>
```
For example:
```
backend/internal/features/system/agent/service.go:42 — [CLAUDE.md] getters take a Get prefix and name the entity — rename Active() to GetRunningVerificationIDs()
backend/internal/features/system/agent/di.go:17 — [backend/CLAUDE.md] never inject another feature's repository — depend on the audit log service, not AuditLogRepository
```
In plan mode a finding cites the plan's section instead of a line number.
On `PASS`, list nothing below the `Audited against:` line.
Report only violations of a rule written in one of the docs you read. Do not invent style preferences the docs do not state. Do not restate what the code does. Do not praise. If a rule is ambiguous as applied to this code, say so in the finding and name the reading you took, rather than silently choosing one.
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Stop hook: blocks once per distinct working-tree state until claude-md-reviewer has audited it.
#
# Blocking is guarded by a marker file keyed on session id + a hash of the diff. Claude Code does
# not document a `stop_hook_active` field, so the marker is the only thing standing between this
# hook and an infinite stop loop. Any git failure exits 0 — never block on a broken repo.
set -uo pipefail
REVIEWED_SOURCE_DIRS=(backend agent frontend)
hook_input=$(cat)
project_dir=${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)} || exit 0
[ -n "$project_dir" ] || exit 0
cd "$project_dir" 2>/dev/null || exit 0
git rev-parse --verify HEAD >/dev/null 2>&1 || exit 0
changed_paths=$(git status --porcelain -- "${REVIEWED_SOURCE_DIRS[@]}" 2>/dev/null) || exit 0
[ -n "$changed_paths" ] || exit 0
# `git diff` omits untracked files, so hash their contents separately. Without this an edit to a
# newly created file would not change the hash, and its review round would be silently skipped.
tree_hash=$(
{
git diff HEAD -- "${REVIEWED_SOURCE_DIRS[@]}" 2>/dev/null
git ls-files --others --exclude-standard -z -- "${REVIEWED_SOURCE_DIRS[@]}" 2>/dev/null |
xargs -0 -r sha256sum 2>/dev/null
} | sha256sum | cut -d' ' -f1
) || exit 0
[ -n "$tree_hash" ] || exit 0
session_id=$(printf '%s' "$hook_input" | jq -r '.session_id // "unknown"' 2>/dev/null)
session_id=${session_id//[^a-zA-Z0-9_-]/}
[ -n "$session_id" ] || session_id=unknown
marker_dir="${TMPDIR:-/tmp}/databasus-claude-review"
mkdir -p "$marker_dir" 2>/dev/null || exit 0
marker="$marker_dir/$session_id-$tree_hash"
[ -e "$marker" ] && exit 0
: >"$marker" 2>/dev/null || exit 0
jq -n '{
decision: "block",
reason: "The working tree has unreviewed changes under backend/, agent/ or frontend/. Invoke the claude-md-reviewer subagent in implementation mode, then resolve every CHANGES REQUIRED finding before stopping. If it returns PASS, stop as normal."
}'
+25
View File
@@ -0,0 +1,25 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "jq -n '{hookSpecificOutput:{hookEventName:\"PostToolUse\",additionalContext:\"Before writing any code, invoke the claude-md-reviewer subagent in plan mode on the approved plan file. Resolve every CHANGES REQUIRED finding before implementing.\"}}'"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/require-implementation-review.sh\""
}
]
}
]
}
}
+76
View File
@@ -0,0 +1,76 @@
language: en-US
reviews:
profile: chill
request_changes_workflow: false
high_level_summary: false
poem: false
review_status: false
commit_status: false
collapse_walkthrough: true
changed_files_summary: false
sequence_diagrams: false
estimate_code_review_effort: false
assess_linked_issues: false
related_issues: false
related_prs: false
suggested_labels: false
auto_apply_labels: false
suggested_reviewers: false
auto_assign_reviewers: false
auto_review:
enabled: true
auto_incremental_review: true
drafts: false
finishing_touches:
docstrings:
enabled: false
path_filters:
- "!**/*.lock"
- "!**/node_modules/**"
- "!**/dist/**"
- "!**/build/**"
- "!**/*.pb.go"
- "!**/*_gen.go"
- "!backend/docs/**"
path_instructions:
- path: ".github/workflows/**"
instructions: |
Security-critical workflow review:
- All third-party actions must be pinned to a full commit SHA, with a "# vX.Y.Z" tag comment. Flag floating tags (@v4, @main, @master).
- Top-level "permissions:" must exist and default to least privilege (contents: read). Flag missing or overly broad top-level permissions.
- Jobs that don't write should not have write scope. Flag contents: write, packages: write, id-token: write that aren't justified by the job's purpose.
- Untrusted PR input (github.event.pull_request.title, body, head.ref) must not be substituted directly into "run:" scripts (workflow injection). Flag and recommend env-var indirection.
- "pull_request_target" with secrets exposed to fork PRs is a serious risk. Flag and require justification.
- path: "**/Dockerfile*"
instructions: |
Docker security:
- Pin base images to a specific version tag and ideally a digest. Flag floating tags like ":latest" or bare ":alpine" without a version.
- Avoid running as root in the final image; flag missing USER directive in production stages.
- Don't embed secrets in build args or COPY. Flag suspicious literal-looking secrets, keys, or tokens.
tools:
gitleaks:
enabled: true
semgrep:
enabled: true
ast-grep:
essential_rules: false
actionlint:
enabled: false
hadolint:
enabled: false
shellcheck:
enabled: false
languagetool:
enabled: false
markdownlint:
enabled: false
chat:
auto_reply: true
+56
View File
@@ -0,0 +1,56 @@
FROM golang:1.26-bookworm
ARG NODE_MAJOR=24
ARG PNPM_VERSION=9.15.9
ARG GOOSE_VERSION=v3.27.1
ARG SWAG_VERSION=v1.16.4
ARG GOLANGCI_LINT_VERSION=v2.11.3
ARG USERNAME=vscode
ARG USER_UID=1000
ARG USER_GID=1000
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl gnupg sudo git \
postgresql-client \
zstd \
python3 python3-pip pre-commit \
libncurses5 libncurses6 libtinfo5 libtinfo6 \
libmariadb3 libgnutls30 \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| gpg --dearmor -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs gh \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable
RUN groupadd --gid ${USER_GID} ${USERNAME} \
&& useradd --uid ${USER_UID} --gid ${USER_GID} --create-home --shell /bin/bash ${USERNAME} \
&& echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/${USERNAME} \
&& chmod 0440 /etc/sudoers.d/${USERNAME} \
&& mkdir -p /home/${USERNAME}/go /home/${USERNAME}/.cache /home/${USERNAME}/.local/share/pnpm \
&& chown -R ${USERNAME}:${USERNAME} /home/${USERNAME}
ENV GOPATH=/home/${USERNAME}/go
ENV PATH=${GOPATH}/bin:${PATH}
USER ${USERNAME}
RUN corepack prepare pnpm@${PNPM_VERSION} --activate
RUN go install github.com/pressly/goose/v3/cmd/goose@${GOOSE_VERSION} \
&& go install github.com/swaggo/swag/cmd/swag@${SWAG_VERSION}
RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \
| sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_LINT_VERSION}
USER root
+54
View File
@@ -0,0 +1,54 @@
{
"name": "Databasus",
"build": {
"dockerfile": "Dockerfile",
"context": "."
},
"workspaceFolder": "/workspaces/databasus",
"workspaceMount": "source=databasus-workspace,target=/workspaces/databasus,type=volume",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"version": "latest",
"moby": true,
"dockerDashComposeVersion": "latest"
}
},
"mounts": [
"source=databasus-go-mod-cache,target=/home/vscode/go/pkg/mod,type=volume",
"source=databasus-go-build-cache,target=/home/vscode/.cache/go-build,type=volume",
"source=databasus-golangci-cache,target=/home/vscode/.cache/golangci-lint,type=volume",
"source=databasus-pre-commit-cache,target=/home/vscode/.cache/pre-commit,type=volume",
"source=databasus-pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"forwardPorts": [4005, 5173],
"portsAttributes": {
"4005": { "label": "Backend (Gin)" },
"5173": { "label": "Vite dev server", "onAutoForward": "openBrowser" }
},
"containerEnv": {
"GOCACHE": "/home/vscode/.cache/go-build",
"GOMODCACHE": "/home/vscode/go/pkg/mod"
},
"customizations": {
"vscode": {
"extensions": [
"golang.go",
"ms-azuretools.vscode-docker",
"redhat.vscode-yaml",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss"
],
"settings": {
"go.lintTool": "golangci-lint",
"go.useLanguageServer": true,
"go.testTimeout": "15m",
"[go]": { "editor.formatOnSave": true },
"eslint.workingDirectories": [{ "directory": "frontend", "changeProcessCWD": true }]
}
}
},
"postCreateCommand": "bash .devcontainer/post-create.sh",
"containerUser": "root",
"remoteUser": "vscode"
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
# The docker-in-docker feature pins iptables to the legacy backend, which needs the
# ip_tables/iptable_nat kernel modules. Hosts running an nft-only firewall stack never
# load them, so dockerd dies initializing the bridge driver. Re-point iptables at the
# nft backend and restart the daemon the entrypoint already gave up on.
if ! docker info >/dev/null 2>&1; then
sudo update-alternatives --set iptables /usr/sbin/iptables-nft
sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-nft
sudo pkill dockerd || true
sudo pkill containerd || true
sudo /usr/local/share/docker-init.sh
fi
sudo chown -R vscode:vscode \
/workspaces/databasus \
/home/vscode/go \
/home/vscode/.cache \
/home/vscode/.local/share/pnpm
cd /workspaces/databasus
cd backend
go mod download
cd ..
cd frontend
pnpm install --frozen-lockfile
cd ..
pre-commit install --install-hooks
+72
View File
@@ -0,0 +1,72 @@
# Git and GitHub
.git
.gitignore
.github
# Node modules everywhere
node_modules
**/node_modules
# Backend - exclude everything except what's needed for build
backend/mysqldata
backend/pgdata
backend/mariadbdata
backend/mongodbdata
backend/temp
backend/images
backend/bin
backend/*.exe
# Scripts and data directories
scripts
databasus-data
# IDE and editor files
.idea
.vscode
.cursor
**/*.swp
**/*.swo
# Documentation and articles (not needed for build)
articles
docs
pages
# Notifiers not needed in container
notifiers
# Dist (will be built fresh)
frontend/dist
# Environment files (handled separately)
.env.local
.env.development
# Logs and temp files
**/*.log
tmp
temp
# OS files
.DS_Store
Thumbs.db
# Helm charts and deployment configs
deploy
# License and other root files
LICENSE
CITATION.cff
*.md
# Assets - exclude SVGs and Windows-only tool binaries (used only for native Windows dev,
# never copied into the Docker image — see Dockerfile)
assets/*.svg
assets/tools/win-x64
# Python cache
**/__pycache__
# Pre-commit config
.pre-commit-config.yaml
+69
View File
@@ -0,0 +1,69 @@
# app
ENV_MODE=development
SHOW_DB_INSTALLATION_VERIFICATION_LOGS=true
# dev DB (used by docker-compose.yml and backend)
DEV_DB_NAME=databasus
DEV_DB_USERNAME=postgres
DEV_DB_PASSWORD=Q1234567
DATABASE_DSN="host=localhost user=postgres password=Q1234567 dbname=databasus port=5437 sslmode=disable"
DATABASE_URL=postgres://postgres:Q1234567@localhost:5437/databasus?sslmode=disable
# test DB (used by backend when IsTesting=true so tests don't pollute the dev DB)
TEST_DATABASE_DSN="host=localhost user=postgres password=Q1234567 dbname=databasus port=5438 sslmode=disable"
# migrations
GOOSE_DRIVER=postgres
GOOSE_DBSTRING=postgres://postgres:Q1234567@localhost:5437/databasus?sslmode=disable
GOOSE_TEST_DBSTRING=postgres://postgres:Q1234567@localhost:5438/databasus?sslmode=disable
# valkey
VALKEY_HOST=127.0.0.1
VALKEY_PORT=6379
VALKEY_USERNAME=
VALKEY_PASSWORD=
VALKEY_IS_SSL=false
# logging
VICTORIA_LOGS_URL=
VICTORIA_LOGS_PASSWORD=
# tests
TEST_PARALLEL_WORKERS=8
TEST_LOCALHOST=localhost
TEST_LOGICAL_POSTGRES_16_PORT=5004
TEST_PHYSICAL_POSTGRES_17_PORT=5007
TEST_PHYSICAL_POSTGRES_18_PORT=5008
# wal-backup (live remote-WAL test rig)
WAL_BACKUP_POSTGRES_18_PORT=5018
WAL_BACKUP_DB_NAME=testdb
WAL_BACKUP_DB_USER=testuser
WAL_BACKUP_DB_PASSWORD=testpassword
# oauth
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# cloudflare turnstile
CLOUDFLARE_TURNSTILE_SITE_KEY=
CLOUDFLARE_TURNSTILE_SECRET_KEY=
# smtp
SMTP_HOST=test-mailpit
SMTP_PORT=1025
SMTP_USER=info@databasus.com
SMTP_PASSWORD=password
SMTP_FROM=info@databasus.com
SMTP_INSECURE_SKIP_VERIFY=false
# domain (used in email links)
DATABASUS_URL=
# frontend (vite)
VITE_GITHUB_CLIENT_ID=
VITE_GOOGLE_CLIENT_ID=
VITE_IS_EMAIL_CONFIGURED=false
VITE_CLOUDFLARE_TURNSTILE_SITE_KEY=
+18
View File
@@ -0,0 +1,18 @@
# Normalize all text files to LF in the repo, regardless of host OS.
# Prevents CRLF noise when checking out on Windows and mounting into the Linux devcontainer.
* text=auto eol=lf
# Keep genuinely binary assets untouched.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.svg text eol=lf
*.ico binary
*.woff binary
*.woff2 binary
*.pdf binary
# Windows-only scripts must keep CRLF.
*.bat text eol=crlf
*.cmd text eol=crlf
+102
View File
@@ -0,0 +1,102 @@
# Code of Conduct
## Our Pledge
We as members, contributors and maintainers pledge to make participation in the Databasus community a friendly and welcoming experience for everyone, regardless of background, experience level or personal circumstances.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive and healthy community.
## Our Standards
### Examples of behavior that contributes to a positive environment
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
- Helping newcomers get started with contributions
- Providing clear and constructive feedback on pull requests
- Celebrating successes and acknowledging contributions
### Examples of unacceptable behavior
- Trolling, insulting or derogatory comments, and personal or political attacks
- Publishing others' private information, such as physical or email addresses, without their explicit permission
- Spam, self-promotion or off-topic content in project spaces
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Scope
This Code of Conduct applies within all community spaces, including:
- GitHub repositories (issues, pull requests, discussions, comments)
- Telegram channels and direct messages related to Databasus
- Social media interactions when representing the project
- Community forums and online discussions
- Any other spaces where Databasus community members interact
This Code of Conduct also applies when an individual is officially representing the community in public spaces, such as using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive or unacceptable behavior may be reported to the community leaders responsible for enforcement:
- **Email**: [info@databasus.com](mailto:info@databasus.com)
- **Telegram**: [@rostislav_dugin](https://t.me/rostislav_dugin)
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Contributing with Respect
When contributing to Databasus, please:
- Be patient with maintainers and other contributors
- Understand that everyone has different levels of experience
- Ask questions in a respectful manner
- Accept that your contribution may not be accepted, and be open to feedback
- Follow the [contribution guidelines](https://databasus.com/contribute)
For code contributions, remember to:
- Discuss significant changes before implementing them
- Be open to code review feedback
- Help review others' contributions when possible
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html).
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq).
+44
View File
@@ -0,0 +1,44 @@
---
name: Bug Report
about: Report a bug or unexpected behavior in Databasus
labels: bug
---
## Databasus version (screenshot)
It is displayed in the bottom left corner of the Databasus UI. Please attach screenshot, not just version text
<!-- e.g. 1.4.2 -->
## Operating system and architecture
<!-- e.g. Ubuntu 22.04 x64, macOS 14 ARM, Windows 11 x64 -->
## Database type and version (optional, for DB-related bugs)
<!-- e.g. PostgreSQL 16 in Docker, MySQL 8.0 installed on server, MariaDB 11.4 in AWS Cloud -->
## Describe the bug (please write manually, do not ask AI to summarize)
**What happened:**
**What I expected:**
## Steps to reproduce
1.
2.
3.
## Have you asked AI how to solve the issue?
<!-- Using AI to diagnose issues before filing a bug report helps narrow down root causes. -->
- [ ] Claude Sonnet 4.6 or newer
- [ ] ChatGPT 5.2 or newer
- [ ] No
## Additional context / logs
<!-- Screenshots, error messages, relevant log output, etc. -->
+66
View File
@@ -0,0 +1,66 @@
# Security Policy
## Reporting a Vulnerability
If you discover a security vulnerability in Databasus, please report it responsibly. **Do not create a public GitHub issue for security vulnerabilities.**
### How to Report
1. **Email** (preferred): Send details to [info@databasus.com](mailto:info@databasus.com)
2. **Telegram**: Contact [@rostislav_dugin](https://t.me/rostislav_dugin)
3. **GitHub Security Advisories**: Use the [private vulnerability reporting](https://github.com/databasus/databasus/security/advisories/new) feature
### What to Include
- Description of the vulnerability
- Steps to reproduce the issue
- Potential impact and severity assessment
- Any suggested fixes (optional)
## Supported Versions
| Version | Supported |
| ------- | --------- |
| Latest | Yes |
We recommend always using the latest version of Databasus. Security patches are applied to the most recent release.
### PostgreSQL Compatibility
Databasus supports PostgreSQL versions 12, 13, 14, 15, 16, 17 and 18.
### MySQL Compatibility
Databasus supports MySQL versions 5.7, 8 and 9.
### MariaDB Compatibility
Databasus supports MariaDB versions 10 and 11.
### MongoDB Compatibility
Databasus supports MongoDB versions 4, 5, 6, 7 and 8.
## Response Timeline
- **Acknowledgment**: Within 48-72 hours
- **Initial Assessment**: Within 1 week
- **Fix Timeline**: Depends on severity, but we aim to address critical issues as quickly as possible
We follow a coordinated disclosure policy. We ask that you give us reasonable time to address the vulnerability before any public disclosure.
## Security Features
Databasus is designed with security in mind. For full details, see our [security documentation](https://databasus.com/security).
Key features include:
- **AES-256-GCM Encryption**: Enterprise-grade encryption for backup files and sensitive data
- **Read-Only Database Access**: Databasus uses read-only access by default and warns if write permissions are detected
- **Role-Based Access Control**: Assign viewer, member, admin or owner roles within workspaces
- **Audit Logging**: Track all system activities and changes made by users
- **Zero-Trust Storage**: Encrypted backups are safe even in shared cloud storage
## License
Databasus is licensed under [Apache 2.0](../LICENSE).
+71
View File
@@ -0,0 +1,71 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
cooldown:
default-days: 7
semver-major-days: 30
semver-minor-days: 7
semver-patch-days: 3
groups:
frontend-minor-and-patch:
update-types: ["minor", "patch"]
labels: ["dependencies", "frontend"]
commit-message:
prefix: "REFACTOR (deps)"
- package-ecosystem: "gomod"
directory: "/backend"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
cooldown:
default-days: 7
semver-major-days: 30
semver-minor-days: 7
semver-patch-days: 3
groups:
backend-minor-and-patch:
update-types: ["minor", "patch"]
labels: ["dependencies", "backend"]
commit-message:
prefix: "REFACTOR (deps)"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
cooldown:
default-days: 14
semver-major-days: 30
semver-minor-days: 14
semver-patch-days: 7
labels: ["dependencies", "docker"]
commit-message:
prefix: "REFACTOR (deps)"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
cooldown:
default-days: 7
semver-major-days: 30
semver-minor-days: 7
semver-patch-days: 3
groups:
actions-minor-and-patch:
update-types: ["minor", "patch"]
labels: ["dependencies", "ci"]
commit-message:
prefix: "REFACTOR (deps)"
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: ["**"]
schedule:
# CodeQL query packs are updated by GitHub over time; a scheduled scan
# surfaces new findings even when no code has changed.
- cron: "0 6 * * 1"
permissions:
contents: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
- language: actions
build-mode: none
steps:
- name: Check out code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Set up Go
if: matrix.language == 'go'
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26.3"
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
queries: security-extended
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
category: "/language:${{ matrix.language }}"
+22
View File
@@ -0,0 +1,22 @@
name: Dependency Review
on:
pull_request:
branches: ["**"]
permissions:
contents: read
pull-requests: write
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Dependency Review
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
with:
fail-on-severity: high
comment-summary-in-pr: on-failure
+26
View File
@@ -0,0 +1,26 @@
ansible/
databasus-data/
.env
pgdata/
pgdata-test/
mysqldata/
mariadbdata/
valkey-data/
victoria-logs-data/
wal-queue/
temp/
node_modules/
.idea
/articles
.DS_Store
/scripts
.vscode/settings.json
.claude/*
!.claude/agents/
!.claude/hooks/
!.claude/settings.json
backend/cleanup_test_db.exe
plans/
.pnpm-store
+44
View File
@@ -0,0 +1,44 @@
# Pre-commit configuration
# See https://pre-commit.com for more information
repos:
# Frontend checks
- repo: local
hooks:
- id: frontend-format
name: Frontend Format (Prettier)
entry: bash -c "cd frontend && npm run format"
language: system
files: ^frontend/.*\.(ts|tsx|js|jsx|json|css|md)$
pass_filenames: false
- id: frontend-lint
name: Frontend Lint (ESLint)
entry: bash -c "cd frontend && npm run lint"
language: system
files: ^frontend/.*\.(ts|tsx|js|jsx)$
pass_filenames: false
- id: frontend-build
name: Frontend Build
entry: bash -c "cd frontend && npm run build"
language: system
files: ^frontend/.*\.(ts|tsx|js|jsx|json|css)$
pass_filenames: false
# Backend checks
- repo: local
hooks:
- id: backend-format-and-lint
name: Backend Format & Lint (golangci-lint)
entry: bash -c "cd backend && golangci-lint fmt ./internal/... ./cmd/... && golangci-lint run ./internal/... ./cmd/..."
language: system
files: ^backend/.*\.go$
pass_filenames: false
- id: backend-go-mod-tidy
name: Backend Go Mod Tidy
entry: bash -c "cd backend && go mod tidy"
language: system
files: ^backend/.*\.go$
pass_filenames: false
+16
View File
@@ -0,0 +1,16 @@
# Trivy misconfiguration findings ignored for this repo.
# See: https://aquasecurity.github.io/trivy/latest/docs/configuration/filtering/
#
# DS-0002 — "Specify at least 1 USER command in Dockerfile with non-root user as argument"
#
# Suppressed for `Dockerfile` because the structural check is a false
# positive for this image:
# - The final stage creates two non-root users (postgres UID 999,
# databasus UID 65532) and the main app process drops to non-root
# via `exec gosu databasus ./main` at the end of start.sh.
# - The entrypoint must start as root to:
# * Remap PUID/PGID at runtime (NAS / Synology / Unraid use case).
# * chown the mounted /databasus-data volume on first boot.
# * Run `initdb` on the embedded PostgreSQL data dir on first boot.
# - Adding a real `USER databasus` directive would break boot.
DS-0002
+36
View File
@@ -0,0 +1,36 @@
cff-version: 1.2.0
title: Databasus
message: "If you use this software, please cite it as below."
type: software
authors:
- family-names: Dugin
given-names: Rostislav
repository-code: https://github.com/databasus/databasus
url: https://databasus.com
abstract: "Free, open source and self-hosted solution for automated databases backups with multiple storage options and notifications."
keywords:
- docker
- kubernetes
- golang
- backups
- postgres
- devops
- backup
- database
- tools
- monitoring
- ftp
- postgresql
- s3
- psql
- web-ui
- self-hosted
- pg
- system-administration
- database-backup
- mysql
- mongodb
- mariadb
license: Apache-2.0
version: 2.21.0
date-released: "2026-01-05"
+147
View File
@@ -0,0 +1,147 @@
# Databasus — Agent Rules and Guidelines
This document contains project-wide coding standards and best practices for Databasus.
This is NOT a strict set of rules — it is a set of recommendations to help write better, more consistent code.
Per-folder rules live next to the code they govern:
- [`backend/CLAUDE.md`](backend/CLAUDE.md) — Go + Gin + GORM + PostgreSQL backend (controllers, migrations, CRUD, DI, testing, logging)
- [`agent/verification/CLAUDE.md`](agent/verification/CLAUDE.md) — Go verification agent CLI (self-update + capacity heartbeat; restore logic deferred)
- [`frontend/CLAUDE.md`](frontend/CLAUDE.md) — React 19 + TypeScript + Vite + Ant Design + Tailwind
This root file holds the engineering philosophy that applies everywhere.
---
## Development environment
Work happens inside the repo's [Dev Container](.devcontainer/devcontainer.json). The container ships Go, Node.js + pnpm, Docker-in-Docker, linters and matching VS Code extensions, so the toolchain is identical for every contributor. Ports `4005` (backend) and `5173` (Vite) are forwarded automatically. Don't install or rely on host-level SDKs — run `make`, `pnpm` and `docker` commands from inside the container.
---
## Language in code
**English only in code, comments, identifiers, log messages, API strings, test assertions, and commit messages.** No other language inside `backend/`, `agent/`, or `frontend/src/` — even for user-facing fallback copy or error messages.
---
## Engineering philosophy
**Think like a skeptical senior engineer and code reviewer. Don't just do what was asked — also think about what should have been asked. Catch real issues, not theoretical ones.**
### Task tiers (scale your response to the task)
- **Trivial** (typos, formatting, single-field adds): apply directly. Steps 5 only.
- **Standard** (CRUD, typical features): steps 1, 5.
- **Complex** (architecture, security, performance-critical): all steps.
- **Unclear** (ambiguous requirements): steps 1 and 4 are mandatory.
### Steps for non-trivial tasks
1. **Restate the objective**, list explicit + inferred assumptions, flag shaky ones.
2. **Propose solutions** — for complex tasks, 23 approaches including a simpler baseline; recommend one with tradeoffs (complexity, maintainability, performance, extensibility).
3. **Identify risks** — edge cases, security/privacy, performance, operational concerns (deployment, observability, rollback). Before finalizing, ask "what could go wrong?" and patch.
4. **Handle ambiguity** — pick a reasonable default, label it, note what changes under alternative assumptions.
5. **Deliver quality** — correct, testable, maintainable code with minimal tests/validation. Prefer controller tests over unit tests.
6. **Fix root causes, not symptoms** — ask "why did this happen?" and address the underlying issue.
### After each run: suggest refactorings
Reread the diff with fresh eyes and **list** (don't silently apply) refactor suggestions: unclear names, duplication, dead code, deep nesting, misplaced responsibilities, leaky abstractions. Keep suggestions concrete (file + lines), behavior-preserving, and scoped to the current change. If the diff is already clean, say so in one line.
### Mandatory compliance review
Every non-trivial change is audited twice by the [`claude-md-reviewer`](.claude/agents/claude-md-reviewer.md) subagent — against this document **and** the module doc for each area it touches (`backend/CLAUDE.md`, `agent/verification/CLAUDE.md`, `frontend/CLAUDE.md`). The module docs add stack-specific rules on top of this one; they never replace it, so a change under `backend/` answers to both.
1. **After planning, before writing code** — it checks the proposed names, file placement, and any planned backward-compat shims while they're still cheap to change.
2. **After implementing, before finishing the turn** — it checks the working-tree diff and runs the linter for each directory the diff touches.
The reviewer is read-only: it reports findings, you apply the fixes. Resolve every `CHANGES REQUIRED` finding before moving on. Hooks in [`.claude/settings.json`](.claude/settings.json) prompt for both checkpoints, but the obligation is this rule, not the hook — honour it if hooks are disabled.
### Naming
Name variables and functions for **intent**, not mechanism. Naming is the biggest readability lever — avoid generic placeholders (`data`, `handle`, `process`, `tmp`, `helper`, `manager`), type-suffix noise (`nameStr`, `agentList`, `tokenObj`), and mechanism-flavored names (`tickNow`, `hbResp`, `dataObj`).
Booleans take an `is` / `can` / `has` / `should` prefix (`isAllowed`, `canAccess`, `hasItems`, `shouldRetry`) — never bare nouns/verbs like `allowed` or `touches`.
State that holds "the entity currently being X-ed" must include the entity: `createdAgent`, not `created`; `listedAgents`, not `listed`; `deletingAgentId`, not `deletingId`. Pair related state explicitly: `revealedToken` + `revealedTokenAgentName`, not `tokenAgentName`. Loop variables get the domain word — `for _, agent := range listedAgents` (Go) — single letters only inside trivial one-line lambdas (`.map((a) => a.id)`).
Test-mechanism names (`got`, `want`, `expected`) are not acceptable — use the domain noun: `persistedAgent`, `firstRotation`, `secondRotation`. Match domain language across the wire — if the API says `agent`, don't rename to `worker` or `node` in client code.
Idiomatic short names stay where the convention is well-established: Go receivers (`s *AgentService`, `r *AgentRepository`, `ctx *gin.Context`) and JS/TS error catches (`} catch (e) {`). When no good name exists, the abstraction is wrong — extract or rename it, don't reach for `helper` / `tmp` / `manager`.
Examples:
```go
// bad
var hbResp HeartbeatResponse
var got *Agent
for _, a := range listed { ... }
// good
var heartbeatResponse HeartbeatResponse
var persistedAgent *Agent
for _, agent := range listedAgents { ... }
```
```ts
// bad
const [tickNow, setTickNow] = useState(Date.now());
const [deletingId, setDeletingId] = useState<string | null>(null);
const [tokenAgentName, setTokenAgentName] = useState('');
// good
const [currentTimeMs, setCurrentTimeMs] = useState(Date.now());
const [deletingAgentId, setDeletingAgentId] = useState<string | null>(null);
const [revealedTokenAgentName, setRevealedTokenAgentName] = useState('');
```
### Functions, methods and types
The intent rule applies to callables and types too — this is where it's violated most:
- **A name that needs a "what" comment is a naming bug.** Doc comments are for *why*, ordering, and hidden constraints — never to restate behavior. If you wrote `// Foo does X` above `func Foo`, rename `Foo` until that comment is redundant, then delete it. Keep only the sentence a name *cannot* carry (e.g. "must be called before the container exists").
- **Predicate methods read as a question**, same as boolean vars: `IsAborted(id)`, not `AbortedContains(id)`; `HasCapacity()`, not `CapacityCheck()`.
- **The name states everything the unit does.** A function that records *and* cancels is `recordAndCancelAborts` — or it's two functions. A name that hides a second effect is a lie.
- **Getters take a `Get` prefix and name the entity.** `GetRunningVerificationIDs()`, not `Active()`, `RunningVerificationIDs()`, or `DiskUsageBytes()`. This is a deliberate house style: prefer the explicit `Get` *even though vanilla Go idiom omits it* — it keeps accessors visually distinct from actions and matches the backend (`GetAuditLogService`, `GetGlobalAuditLogs`). Constructors stay `New...`; a getter that returns a bool is still a predicate, so it stays `Is/Has...`, not `Get...`.
- **Long positional parameter lists become a struct.** ~4+ params, or two same-typed params adjacent → a named parameter/DTO struct (the input counterpart to the result type, e.g. `spawnPlan``SpawnSpec`). Kills call-site ordering bugs and the comment explaining argument order.
- **Type names: avoid generic *and* avoid package stutter.** Not `Manager` / `Provisioner` / `Handler` (generic), not `container.ContainerManager` (stutters — `revive` fails CI). If the only honest name stutters, put the noun on the *variable*, not the type: keep the type `container.Manager`, name the variable `containerManager`. If no precise type name exists, the abstraction is wrong — split it.
```go
// bad — comment restates the name; name hides the second effect; predicate isn't a question
// applyAborts records the abort set and cancels each registered job.
func (h *Heartbeater) applyAborts(ids []uuid.UUID) { ... }
func (h *Heartbeater) AbortedContains(id uuid.UUID) bool { ... }
// good — the names carry it; no comment needed
func (h *Heartbeater) recordAndCancelAborts(ids []uuid.UUID) { ... }
func (h *Heartbeater) IsAborted(id uuid.UUID) bool { ... }
```
### Linting and formatting
After each change run linting and formatting depending on folder you are working it.
- backend and agent has `make lint` commands
- frontend has `pnpm lint` and `pnpm format` commands
### Comments are a last resort
The default is **no comment**. Before writing one, reach for a clearer name or a smaller function first — self-explanatory code beats commented code every time. A comment is justified only when it carries something the code cannot: a *why* (business rule, hidden cross-system constraint, non-obvious algorithm or optimisation, ADR reference). Never write a comment that restates *what* the code does or narrates the obvious — if a `// Foo does X` comment sits above the code, that's a naming bug: rename until the comment is redundant, then delete it. This applies everywhere, tests included — a well-named test needs no header comment explaining what it checks.
### No "how it was" comments, no unrequested backward compatibility
Don't write comments that explain previous behavior ("used to be X", "was renamed from Y", "kept for legacy callers"). Code shows the current state; history lives in git.
Don't preserve backward compatibility unless the user asks for it. No deprecation shims, no aliases, no fallbacks for the old shape. When planning a change that would break existing callers, schemas, configs, or APIs, call out the break explicitly in the plan. If the user approves it, delete the old code outright — do not leave a transition layer behind.
### Security
Databasus handles sensitive data, so security is a layered defence. CodeQL, CodeRabbit, Codex Security, Trivy, Dependabot, gitleaks and semgrep all run on every PR. When working in the repo:
- Don't disable or weaken security checks to make a build pass. For genuine false positives, suppress explicitly with a documented reason (e.g. `.trivyignore`) and explain why in the PR.
- Pin every new GitHub Action to a full commit SHA with a `# vX.Y.Z` tag comment. No floating tags like `@v4` or `@main`.
- Workflows default to top-level `permissions: contents: read`; elevate per-job only when justified.
- Keep Dockerfiles free of secrets, floating base-image tags and unjustified root. If root-at-start is required (PUID/PGID remap, volume chown, initdb), drop privileges with `gosu` before `exec`-ing the app.
- Never log secrets, tokens or credentials. Redact at the logger layer, not at call sites.
The README's `🛡️ Security & reliability engineering` section is the public-facing version of these practices — keep both consistent if substance changes.
+443
View File
@@ -0,0 +1,443 @@
# ========= BUILD FRONTEND =========
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-build
WORKDIR /frontend
# Add version for the frontend build
ARG APP_VERSION=dev
ENV VITE_APP_VERSION=$APP_VERSION
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY frontend/ ./
# Copy .env file (with fallback to .env.production.example)
RUN if [ ! -f .env ]; then \
if [ -f .env.production.example ]; then \
cp .env.production.example .env; \
fi; \
fi
RUN pnpm build
# ========= BUILD BACKEND =========
# Backend build stage
FROM --platform=$BUILDPLATFORM golang:1.26.3 AS backend-build
# Make TARGET args available early so tools built here match the final image arch
ARG TARGETOS
ARG TARGETARCH
# Install Go public tools needed in runtime. Use `go build` for goose so the
# binary is compiled for the target architecture instead of downloading a
# prebuilt binary which may have the wrong architecture (causes exec format
# errors on ARM).
RUN git clone --depth 1 --branch v3.27.1 https://github.com/pressly/goose.git /tmp/goose && \
cd /tmp/goose/cmd/goose && \
GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \
go build -o /usr/local/bin/goose . && \
rm -rf /tmp/goose
RUN go install github.com/swaggo/swag/cmd/swag@v1.16.4
# Set working directory
WORKDIR /app
# Install Go dependencies
COPY backend/go.mod backend/go.sum ./
RUN go mod download
# Create required directories for embedding
RUN mkdir -p /app/ui/build
# Copy frontend build output for embedding
COPY --from=frontend-build /frontend/dist /app/ui/build
# Generate Swagger documentation
COPY backend/ ./
RUN swag init -d . -g cmd/main.go -o swagger
# Compile the backend
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
RUN CGO_ENABLED=0 \
GOOS=$TARGETOS \
GOARCH=$TARGETARCH \
go build -o /app/main ./cmd
# ========= BUILD VERIFICATION AGENT =========
FROM --platform=$BUILDPLATFORM golang:1.26.3 AS verification-agent-build
ARG APP_VERSION=dev
WORKDIR /agent
COPY agent/verification/go.mod agent/verification/go.sum ./
RUN go mod download
COPY agent/verification/ ./
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags "-X main.Version=${APP_VERSION}" \
-o /verification-agent-binaries/databasus-verification-agent-linux-amd64 ./cmd
RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
go build -ldflags "-X main.Version=${APP_VERSION}" \
-o /verification-agent-binaries/databasus-verification-agent-linux-arm64 ./cmd
# ========= RUNTIME =========
# In this final image we ship only what the running app actually needs:
# - Build-only tooling (compilers, codegen, key fetchers) stays in the earlier
# stages above and never reaches here.
# - Anything pulled in for a single build step is purged within the same layer.
# - We keep this tight because every extra binary widens the attack surface and
# adds another CVE to track.
FROM debian:bookworm-slim
# Add version metadata to runtime image
ARG APP_VERSION=dev
ARG TARGETARCH
LABEL org.opencontainers.image.version=$APP_VERSION
ENV APP_VERSION=$APP_VERSION
ENV CONTAINER_ARCH=$TARGETARCH
# Set production mode for Docker containers
ENV ENV_MODE=production
# ========= Install all apt packages in a single layer =========
# Base packages + PostgreSQL 17 (pgdg repo) + Valkey (greensec repo) + rclone, in
# one RUN to minimise layer count and cache-export overhead.
#
# - wget: build-only — fetches the repo signing keys, then purged at the end of
# this RUN (see the "minimal attack surface" note on the runtime stage above).
# - Repo keys: scoped signed-by keyrings, not the deprecated global apt-key trust
# store, so a compromised repo key cannot vouch for any other repository.
# - Codename: hardcoded "bookworm" (base image is pinned), so no lsb-release.
# - Valkey: bound to localhost only — never exposed outside the container.
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
ca-certificates gosu rclone \
libncurses5 libncurses6 libmariadb3 libgnutls30 \
wget; \
wget -qO /usr/share/keyrings/pgdg.asc https://www.postgresql.org/media/keys/ACCC4CF8.asc; \
echo "deb [signed-by=/usr/share/keyrings/pgdg.asc] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list; \
wget -qO /usr/share/keyrings/greensec.github.io-valkey-debian.key \
https://greensec.github.io/valkey-debian/public.key; \
echo "deb [signed-by=/usr/share/keyrings/greensec.github.io-valkey-debian.key] https://greensec.github.io/valkey-debian/repo bookworm main" \
> /etc/apt/sources.list.d/valkey-debian.list; \
apt-get update; \
apt-get install -y --no-install-recommends postgresql-17 valkey; \
apt-get purge -y --auto-remove wget; \
rm -rf /var/lib/apt/lists/*
# ========= Pre-built DB client binaries (PG, MySQL, MariaDB, MongoDB) =========
# All client tools live under /app/assets/tools/<arch>/ — the backend resolves
# them at runtime via runtime.GOARCH. Use a bind mount so only the tree matching
# $TARGETARCH ends up in an image layer (the unused arch never materialises).
ARG TARGETARCH
RUN --mount=type=bind,source=assets/tools,target=/ctx/tools,readonly \
mkdir -p /app/assets/tools && \
if [ "$TARGETARCH" = "amd64" ]; then \
cp -r /ctx/tools/x64 /app/assets/tools/x64; \
elif [ "$TARGETARCH" = "arm64" ]; then \
cp -r /ctx/tools/arm /app/assets/tools/arm; \
fi && \
chmod +x /app/assets/tools/*/postgresql/*/bin/* \
/app/assets/tools/*/mysql/*/bin/* \
/app/assets/tools/*/mariadb/*/bin/* \
/app/assets/tools/*/mongodb/bin/*
# Create postgres user and set up directories
RUN groupadd -g 999 postgres || true && \
useradd -m -s /bin/bash -u 999 -g 999 postgres || true && \
mkdir -p /databasus-data/pgdata && \
chown -R postgres:postgres /databasus-data/pgdata
# Create non-root user for the main application process
RUN useradd -r -s /usr/sbin/nologin -u 65532 databasus
WORKDIR /app
# Copy Goose from build stage
COPY --from=backend-build /usr/local/bin/goose /usr/local/bin/goose
# Copy app binary
COPY --from=backend-build /app/main .
# Expose the binary as the `databasus` command on PATH (e.g. `databasus healthcheck`)
RUN ln -s /app/main /usr/local/bin/databasus
# Copy migrations directory
COPY backend/migrations ./migrations
# Copy UI files
COPY --from=backend-build /app/ui/build ./ui/build
# Copy verification agent binaries (both architectures) — served by the backend
# at GET /api/v1/system/verification-agent?arch=amd64|arm64
RUN mkdir -p ./agent-binaries
COPY --from=verification-agent-build /verification-agent-binaries/* ./agent-binaries/
# Bake .env.example as /.env so the binary has defaults when no env file is
# mounted. The backend looks for .env at the parent of cwd (= /app), i.e. /.
# Real env vars (-e, compose, k8s) take precedence — godotenv.Load does not
# overwrite already-set variables.
COPY .env.example /.env
# Create startup script
COPY <<EOF /app/start.sh
#!/bin/bash
set -e
# Check for legacy postgresus-data volume mount
if [ -d "/postgresus-data" ] && [ "\$(ls -A /postgresus-data 2>/dev/null)" ]; then
echo ""
echo "=========================================="
echo "ERROR: Legacy volume detected!"
echo "=========================================="
echo ""
echo "You are using the \`postgresus-data\` folder. It seems you changed the image name from Postgresus to Databasus without changing the volume."
echo ""
echo "Please either:"
echo " 1. Switch back to image rostislavdugin/postgresus:latest (supported until ~Dec 2026)"
echo " 2. Read the migration guide: https://databasus.com/installation/#postgresus-migration"
echo ""
echo "=========================================="
exit 1
fi
# ========= Adjust postgres user UID/GID =========
PUID=\${PUID:-999}
PGID=\${PGID:-999}
CURRENT_UID=\$(id -u postgres)
CURRENT_GID=\$(id -g postgres)
if [ "\$CURRENT_GID" != "\$PGID" ]; then
echo "Adjusting postgres group GID from \$CURRENT_GID to \$PGID..."
groupmod -o -g "\$PGID" postgres
fi
if [ "\$CURRENT_UID" != "\$PUID" ]; then
echo "Adjusting postgres user UID from \$CURRENT_UID to \$PUID..."
usermod -o -u "\$PUID" postgres
fi
# PostgreSQL 17 binary paths
PG_BIN="/usr/lib/postgresql/17/bin"
# Generate runtime configuration for frontend
echo "Generating runtime configuration..."
# Detect if email is configured (both SMTP_HOST and DATABASUS_URL must be set)
if [ -n "\${SMTP_HOST:-}" ] && [ -n "\${DATABASUS_URL:-}" ]; then
IS_EMAIL_CONFIGURED="true"
else
IS_EMAIL_CONFIGURED="false"
fi
cat > /app/ui/build/runtime-config.js <<JSEOF
// Runtime configuration injected at container startup
// This file is generated dynamically and should not be edited manually
window.__RUNTIME_CONFIG__ = {
GITHUB_CLIENT_ID: '\${GITHUB_CLIENT_ID:-}',
GOOGLE_CLIENT_ID: '\${GOOGLE_CLIENT_ID:-}',
IS_EMAIL_CONFIGURED: '\$IS_EMAIL_CONFIGURED',
CLOUDFLARE_TURNSTILE_SITE_KEY: '\${CLOUDFLARE_TURNSTILE_SITE_KEY:-}',
CONTAINER_ARCH: '\${CONTAINER_ARCH:-unknown}'
};
JSEOF
# Inject analytics script if provided (only if not already injected)
if [ -n "\${ANALYTICS_SCRIPT:-}" ]; then
if ! grep -q "rybbit.databasus.com" /app/ui/build/index.html 2>/dev/null; then
echo "Injecting analytics script..."
sed -i "s#</head># \${ANALYTICS_SCRIPT}\\
</head>#" /app/ui/build/index.html
fi
fi
# Ensure proper ownership of data directory
echo "Setting up data directory permissions..."
mkdir -p /databasus-data/pgdata
mkdir -p /databasus-data/temp
mkdir -p /databasus-data/backups
chown databasus:databasus /databasus-data
chown -R postgres:postgres /databasus-data/pgdata
chown -R databasus:databasus /databasus-data/temp /databasus-data/backups
# Upgrade path: secret.key and instance.json may be owned by root or postgres
# from older images. Re-own them so the non-root main process can read/write.
chown databasus:databasus /databasus-data/secret.key /databasus-data/instance.json 2>/dev/null || true
chmod 700 /databasus-data/temp
# ========= Start Valkey (internal cache) =========
echo "Configuring Valkey cache..."
cat > /tmp/valkey.conf << 'VALKEY_CONFIG'
port 6379
bind 127.0.0.1
protected-mode yes
save ""
maxmemory 256mb
maxmemory-policy allkeys-lru
VALKEY_CONFIG
echo "Starting Valkey..."
valkey-server /tmp/valkey.conf &
VALKEY_PID=\$!
echo "Waiting for Valkey to be ready..."
for i in {1..30}; do
if valkey-cli ping >/dev/null 2>&1; then
echo "Valkey is ready!"
break
fi
sleep 1
done
# Initialize PostgreSQL if not already initialized
if [ ! -s "/databasus-data/pgdata/PG_VERSION" ]; then
echo "Initializing PostgreSQL database..."
gosu postgres \$PG_BIN/initdb -D /databasus-data/pgdata --encoding=UTF8 --locale=C.UTF-8
# Configure PostgreSQL
echo "host all all 127.0.0.1/32 md5" >> /databasus-data/pgdata/pg_hba.conf
echo "local all all trust" >> /databasus-data/pgdata/pg_hba.conf
echo "port = 5437" >> /databasus-data/pgdata/postgresql.conf
echo "listen_addresses = 'localhost'" >> /databasus-data/pgdata/postgresql.conf
echo "shared_buffers = 256MB" >> /databasus-data/pgdata/postgresql.conf
echo "max_connections = 100" >> /databasus-data/pgdata/postgresql.conf
fi
# Function to start PostgreSQL and wait for it to be ready
start_postgres() {
echo "Starting PostgreSQL..."
# -k /tmp: create Unix socket and lock file in /tmp instead of /var/run/postgresql/.
# On NAS systems (e.g. TrueNAS Scale), the ZFS-backed Docker overlay filesystem
# ignores chown/chmod on directories from image layers, so PostgreSQL gets
# "Permission denied" when creating .s.PGSQL.5437.lock in /var/run/postgresql/.
# All internal connections use TCP (-h localhost), so the socket location does not matter.
gosu postgres \$PG_BIN/postgres -D /databasus-data/pgdata -p 5437 -k /tmp &
POSTGRES_PID=\$!
echo "Waiting for PostgreSQL to be ready..."
for i in {1..30}; do
if gosu postgres \$PG_BIN/pg_isready -p 5437 -h localhost >/dev/null 2>&1; then
echo "PostgreSQL is ready!"
return 0
fi
sleep 1
done
return 1
}
# Try to start PostgreSQL
if ! start_postgres; then
echo ""
echo "=========================================="
echo "PostgreSQL failed to start. Attempting WAL reset recovery..."
echo "=========================================="
echo ""
# Kill any remaining postgres processes
pkill -9 postgres 2>/dev/null || true
sleep 2
# Attempt pg_resetwal to recover from WAL corruption
echo "Running pg_resetwal to reset WAL..."
if gosu postgres \$PG_BIN/pg_resetwal -f /databasus-data/pgdata; then
echo "WAL reset successful. Restarting PostgreSQL..."
# Try starting PostgreSQL again after WAL reset
if start_postgres; then
echo "PostgreSQL recovered successfully after WAL reset!"
else
echo ""
echo "=========================================="
echo "ERROR: PostgreSQL failed to start even after WAL reset."
echo "The database may be severely corrupted."
echo ""
echo "Options:"
echo " 1. Delete the volume and start fresh (data loss)"
echo " 2. Manually inspect /databasus-data/pgdata for issues"
echo "=========================================="
exit 1
fi
else
echo ""
echo "=========================================="
echo "ERROR: pg_resetwal failed."
echo "The database may be severely corrupted."
echo ""
echo "Options:"
echo " 1. Delete the volume and start fresh (data loss)"
echo " 2. Manually inspect /databasus-data/pgdata for issues"
echo "=========================================="
exit 1
fi
fi
# Create database and set password for postgres user
echo "Setting up database and user..."
gosu postgres \$PG_BIN/psql -p 5437 -h localhost -d postgres << 'SQL'
-- We use stub password, because internal DB is not exposed outside container
ALTER USER postgres WITH PASSWORD 'Q1234567';
SELECT 'CREATE DATABASE databasus OWNER postgres'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'databasus')
\\gexec
\\q
SQL
# ========= Refuse to start if legacy WAL backup data exists =========
# The agent-based WAL_V1 backup type was removed. Existing installs with
# WAL-mode databases must downgrade, manually remove them and then upgrade.
echo "Checking for legacy WAL backup configuration..."
WAL_CHECK_DSN="postgres://postgres:Q1234567@localhost:5437/databasus"
WAL_CHECK_COL=\$(gosu databasus \$PG_BIN/psql "\$WAL_CHECK_DSN" -tA -c "SELECT 1 FROM information_schema.columns WHERE table_name='postgresql_databases' AND column_name='backup_type' LIMIT 1" 2>/dev/null || true)
if [ "\$WAL_CHECK_COL" = "1" ]; then
WAL_CHECK_ROW=\$(gosu databasus \$PG_BIN/psql "\$WAL_CHECK_DSN" -tA -c "SELECT 1 FROM postgresql_databases WHERE backup_type='WAL_V1' LIMIT 1" 2>/dev/null || true)
if [ "\$WAL_CHECK_ROW" = "1" ]; then
echo ""
echo "=========================================="
echo "ERROR: Agent (WAL_V1) backup approach is no longer supported."
echo "=========================================="
echo ""
echo "Please downgrade to version 3.42.0, remove all WAL-mode databases"
echo "manually and then upgrade again. This safeguard exists to avoid"
echo "corrupting already-set-up agents."
echo ""
echo "=========================================="
exit 1
fi
fi
echo "No legacy WAL backup data detected."
# Start the main application
echo "Starting Databasus application..."
exec gosu databasus ./main
EOF
LABEL org.opencontainers.image.source="https://github.com/databasus/databasus"
RUN chmod +x /app/start.sh
EXPOSE 4005
# Liveness probe: the runtime image ships no wget/curl, so the binary checks
# itself. Targets the dependency-free /system/version endpoint (not the deep
# /system/health) so a degraded-but-serving instance is never restarted.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD ["databasus", "healthcheck"]
# Volume for PostgreSQL data
VOLUME ["/databasus-data"]
ENTRYPOINT ["/app/start.sh"]
CMD []
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(which shall not include communications that are solely written
by You).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based upon (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and derivative works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control
systems, and issue tracking systems that are managed by, or on behalf
of, the Licensor for the purpose of discussing and improving the Work,
but excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to use, reproduce, modify, distribute, prepare
Derivative Works of, and publicly display, publicly perform,
sublicense, and distribute the Work and such Derivative Works in
Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright notice to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. When redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "license" line as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Databasus
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+22
View File
@@ -0,0 +1,22 @@
Copyright © 20252026 Rostislav Dugin and contributors.
“Databasus” is a trademark of Rostislav Dugin.
The source code in this repository is licensed under the Apache License, Version 2.0.
That license applies to the code only and does not grant any right to use the
Databasus name, logo, or branding, except for reasonable and customary referential
use in describing the origin of the software and reproducing the content of this NOTICE.
Permitted referential use includes truthful use of the name “Databasus” to identify
the original Databasus project in software catalogs, deployment templates, hosting
panels, package indexes, compatibility pages, integrations, tutorials, reviews, and
similar informational materials, including phrases such as “Databasus”,
“Deploy Databasus”, “Databasus on Coolify”, and “Compatible with Databasus”.
You may not use “Databasus” as the name or primary branding of a competing product,
service, fork, distribution, or hosted offering, or in any manner likely to cause
confusion as to source, affiliation, sponsorship, or endorsement.
Nothing in this repository transfers, waives, limits, or estops any rights in the
Databasus mark. All trademark rights are reserved except for the limited referential
use stated above.
+317
View File
@@ -0,0 +1,317 @@
<div align="center">
<img src="assets/logo.svg" alt="Databasus Logo" width="250"/>
<h3>PostgreSQL backup tool</h3>
<p>Databasus is a free, open source and self-hosted tool to backup PostgreSQL. Make backups with different storages (S3, Google Drive, FTP, etc.) and notifications about progress (Slack, Discord, Telegram, etc.). With a focus on Point-in-Time Recovery at low RPO/RTO</p>
<!-- Badges -->
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-336791?logo=postgresql&logoColor=white)](https://www.postgresql.org/)
[![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)](https://www.mysql.com/)
[![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/)
[![MongoDB](https://img.shields.io/badge/MongoDB-47A248?logo=mongodb&logoColor=white)](https://www.mongodb.com/)
<br />
[![Apache 2.0 License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
[![Docker Pulls](https://img.shields.io/docker/pulls/databasus/databasus?color=brightgreen)](https://hub.docker.com/r/databasus/databasus)
[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/databasus/databasus)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/databasus/databasus)
[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/databasus/databasus)
<p>
<a href="#-features">Features</a> •
<a href="#-installation">Installation</a> •
<a href="#-usage">Usage</a> •
<a href="#-license">License</a> •
<a href="#-contributing">Contributing</a>
</p>
<p style="margin-top: 20px; margin-bottom: 20px; font-size: 1.2em;">
<a href="https://databasus.com" target="_blank"><strong>🌐 Databasus website</strong></a>
</p>
<img src="assets/dashboard-dark.svg" alt="Databasus Dark Dashboard" width="800" style="margin-bottom: 10px;"/>
<img src="assets/dashboard.svg" alt="Databasus Dashboard" width="800"/>
</div>
---
## ✨ Features
### 📦 **Backup types**
- **Physical**: file-level copy of the entire database cluster over PostgreSQL native incremental backups mechanism (read more)
- **Full**: a complete, self-contained copy of the cluster
- **Incremental**: stores only what changed since the previous full backup, so backups stay small and fast
- **WAL streaming**: continuously captures the database write stream, enabling Point-in-time recovery (PITR). Designed for disaster recovery and near-zero data loss
- **Logical**: native dump of the database in its engine-specific binary format (compressed, suitable for parallel restore)
### 🔄 **Scheduled backups**
- **Flexible scheduling**: hourly, daily, weekly, monthly or cron
- **Precise timing**: run backups at specific times (e.g., 4 AM during low traffic)
- **Smart compression**: 4-8x space savings with balanced compression (~20% overhead)
### 🧪 **Restore verification** <a href="https://databasus.com/restore-verification">(docs)</a>
Databasus performs a real restore to confirm backups are usable, not just intact on disk or checksum check.
- **Triggers**: after each backup or on a flexible schedule (hourly, daily, weekly, monthly or cron)
- **Real restore**: spins up a database container, runs the restore and checks the restored size against the backup
- **Report**: lists every table with its row count
- **Optional notifications**: send the report or failure-only alerts through any configured notifier
### 🗑️ **Retention policies**
- **Time period**: Keep backups for a fixed duration (e.g., 7 days, 3 months, 1 year)
- **Count**: Keep a fixed number of the most recent backups (e.g., last 30)
- **GFS (Grandfather-Father-Son)**: Layered retention — keep hourly, daily, weekly, monthly and yearly backups independently for fine-grained long-term history (enterprises requirement)
- **Size limits**: Set per-backup and total storage size caps to control storage usage
### 🗄️ **Multiple storage destinations** <a href="https://databasus.com/storages">(view supported)</a>
- **Local storage**: Keep backups on your VPS/server
- **Cloud storage**: S3, Cloudflare R2, Google Drive, NAS, Dropbox, SFTP, Rclone and more
- **Secure**: All data stays under your control
### 📱 **Notifications** <a href="https://databasus.com/notifiers">(view supported)</a>
- **Multiple channels**: Email, Telegram, Slack, Discord, webhooks
- **Real-time updates**: Success and failure notifications
- **Team integration**: Perfect for DevOps workflows
### 🔒 **Enterprise-grade security** <a href="https://databasus.com/security">(docs)</a>
- **AES-256-GCM encryption**: Enterprise-grade protection for backup files
- **Zero-trust storage**: Backups are encrypted and remain useless to attackers, so you can safely store them in shared storage like S3, Azure Blob Storage, etc.
- **Encryption for secrets**: Any sensitive data is encrypted and never exposed, even in logs or error messages
- **Read-only user**: Databasus uses a read-only user by default for backups and never stores anything that can modify your data
### 👥 **Suitable for teams** <a href="https://databasus.com/access-management">(docs)</a>
- **Workspaces**: Group databases, notifiers and storages for different projects or teams
- **Access management**: Control who can view or manage specific databases with role-based permissions
- **Audit logs**: Track all system activities and changes made by users
- **User roles**: Assign viewer, member, admin or owner roles within workspaces
### 🎨 **UX-Friendly**
- **Designer-polished UI**: Clean, intuitive interface crafted with attention to detail
- **Dark & light themes**: Choose the look that suits your workflow
- **Mobile adaptive**: Check your backups from anywhere on any device
### 💾 **Supported databases**
- **PostgreSQL**: 14, 15, 16, 17 and 18 (physical and logical)
- **MySQL**: 5.7 and 8 (logical only)
- **MariaDB**: 10, 11 and 12 (logical only)
- **MongoDB**: 4.2+, 5, 6, 7 and 8 (logical only)
-
### 🐳 **Self-hosted & secure**
- **Docker-based**: Easy deployment and management
- **Privacy-first**: All your data stays on your infrastructure
- **Open source**: Apache 2.0 licensed, inspect every line of code
### 📦 Installation <a href="https://databasus.com/installation">(docs)</a>
You have four ways to install Databasus:
- Automated script (recommended)
- Simple Docker run
- Docker Compose setup
- Kubernetes with Helm
<img src="assets/healthchecks.svg" alt="Databasus Dashboard" width="800"/>
---
## 📦 Installation
You have four ways to install Databasus: automated script (recommended), simple Docker run, or Docker Compose setup.
### Option 1: Automated installation script (recommended, Linux only)
The installation script will:
- ✅ Install Docker with Docker Compose (if not already installed)
- ✅ Set up Databasus
- ✅ Configure automatic startup on system reboot
```bash
sudo apt-get install -y curl && \
sudo curl -sSL https://raw.githubusercontent.com/databasus/databasus/refs/heads/main/install-databasus.sh \
| sudo bash
```
### Option 2: Simple Docker run
The easiest way to run Databasus:
```bash
docker run -d \
--name databasus \
-p 4005:4005 \
-v ./databasus-data:/databasus-data \
--restart unless-stopped \
databasus/databasus:latest
```
This single command will:
- ✅ Start Databasus
- ✅ Store all data in `./databasus-data` directory
- ✅ Automatically restart on system reboot
### Option 3: Docker Compose setup
Create a `docker-compose.yml` file with the following configuration:
```yaml
services:
databasus:
container_name: databasus
image: databasus/databasus:latest
ports:
- "4005:4005"
volumes:
- ./databasus-data:/databasus-data
restart: unless-stopped
healthcheck:
test: ["CMD", "databasus", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
```
Then run:
```bash
docker compose up -d
```
### Option 4: Kubernetes with Helm
For Kubernetes deployments, install directly from the OCI registry.
**With ClusterIP + port-forward (development/testing):**
```bash
helm install databasus oci://ghcr.io/databasus/charts/databasus \
-n databasus --create-namespace
```
```bash
kubectl port-forward svc/databasus-service 4005:4005 -n databasus
# Access at http://localhost:4005
```
**With LoadBalancer (cloud environments):**
```bash
helm install databasus oci://ghcr.io/databasus/charts/databasus \
-n databasus --create-namespace \
--set service.type=LoadBalancer
```
```bash
kubectl get svc databasus-service -n databasus
# Access at http://<EXTERNAL-IP>:4005
```
**With Ingress (domain-based access):**
```bash
helm install databasus oci://ghcr.io/databasus/charts/databasus \
-n databasus --create-namespace \
--set ingress.enabled=true \
--set ingress.hosts[0].host=backup.example.com
```
For more options (NodePort, TLS, HTTPRoute for Gateway API), see the [Helm chart README](deploy/helm/README.md).
---
## 🚀 Usage
1. **Access the dashboard**: Navigate to `http://localhost:4005`
2. **Add your first database for backup**: Click "New Database" and follow the setup wizard
3. **Configure schedule**: Choose from hourly, daily, weekly, monthly or cron intervals
4. **Set database connection**: Enter your database credentials and connection details
5. **Choose storage**: Select where to store your backups (local, S3, Google Drive, etc.)
6. **Configure retention policy**: Choose time period, count or GFS to control how long backups are kept
7. **Add notifications** (optional): Configure email, Telegram, Slack, or webhook notifications
8. **Save and start**: Databasus will validate settings and begin the backup schedule
### 🔑 Resetting password <a href="https://databasus.com/password">(docs)</a>
If you need to reset the password, you can use the built-in password reset command:
```bash
docker exec -it databasus ./main --new-password="YourNewSecurePassword123" --email="admin"
```
Replace `admin` with the actual email address of the user whose password you want to reset.
### 💾 Backuping Databasus itself
After installation, it is also recommended to <a href="https://databasus.com/faq#backup-databasus">backup your Databasus itself</a> or, at least, to copy secret key used for encryption (30 seconds is needed). So you are able to restore from your encrypted backups if you lose access to the server with Databasus or it is corrupted.
---
## 🛡️ Security & reliability engineering
Databasus works with sensitive data, so preventing vulnerabilities, unauthorised access and data leaks is a primary concern. We invest in this on both sides of the system: in the code itself (permission checks, encryption, careful handling of secrets) and in the infrastructure around it (dependency analysis, CVE response, DevSecOps best practices). The pipeline below runs automatically on every commit and PR. No single layer is enough on its own, but together they reduce the chance of vulnerable code, unsafe dependencies, broken images, or non-restorable backups reaching a release.
For static analysis we combine several independent passes. CodeQL scans the full codebase for security issues. CodeRabbit reviews every PR and runs gitleaks for secret scanning and semgrep for security rules inline. Dockerfiles and CI workflows get extra rules of their own (pinned action references, least-privilege permissions, suspicious base images), so insecure patterns are flagged before they ever merge. On top of these per-PR checks, Codex Security from OpenAI runs regular, deeper audits of the whole codebase. It's a separate program that catches architectural and cross-cutting issues narrow PR-time scans can miss.
On the dependency side, Dependabot watches all of our dependencies against the GitHub Advisory Database and surfaces CVEs within minutes of publication. Updates run through a cooldown so newly-published versions get a chance to mature before we adopt them. This is a deliberate defence against compromised-package incidents like supply-chain attack. The Dependency Review Action blocks any PR that introduces a new HIGH or CRITICAL CVE outright.
Container images are scanned with Trivy on every build. A separate Trivy pass on the Dockerfile catches misconfigurations before they make it into an image. All GitHub Actions are pinned to full commit SHAs rather than floating tags like `@v4` or `@main`, which have been an active attack vector in 2025. Workflows default to least-privilege permissions and only elevate per-job when genuinely needed.
Critical paths are covered by both unit and integration tests, run against real database containers for every supported engine and major version. Restore is the path that matters most for a backup tool, so we test it explicitly: every PR runs full backup-then-restore cycles against those same real containers, verifying that backups can actually be restored end-to-end, not just written successfully. The rest of the CI/CD pipeline runs lint, type-check, the full test suite, image smoke tests and multi-architecture builds on every PR. A release only ships if all of it passes.
Found a vulnerability? Report it via the GitHub Security tab. See [SECURITY.md](https://github.com/databasus/databasus?tab=security-ov-file#readme). Security reports are the highest-priority work queue. For runtime application security (AES-256-GCM at rest, zero-trust storage, encrypted secrets, read-only DB user by default) see [Enterprise-grade security](#-enterprise-grade-security) in the Features section above.
---
## 📝 License
This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details
## 🤝 Contributing
Contributions are welcome! Read the <a href="https://databasus.com/contribute">contributing guide</a> for more details, priorities and rules. If you want to contribute but don't know where to start, message me on Telegram [@rostislav_dugin](https://t.me/rostislav_dugin)
Also you can join our large community of developers, DBAs and DevOps engineers on Telegram [@databasus_community](https://t.me/databasus_community).
## AI disclaimer
There have been questions about AI usage in project development in issues and discussions. As the project focuses on security, reliability and production usage, it's important to explain how AI is used in the development process.
First of all, we are proud to say that Databasus has been accepted into both [Claude for Open Source](https://claude.com/contact-sales/claude-for-oss) by Anthropic and [Codex for Open Source](https://developers.openai.com/codex/community/codex-for-oss/) by OpenAI in March 2026. For us it is one more signal that the project was recognized as important open-source software and was as critical infrastructure worth supporting independently by two of the world's leading AI companies. Read more at [databasus.com/faq](https://databasus.com/faq#oss-programs).
Despite of this, we have the following rules how AI is used in the development process:
AI is used as a helper for:
- verification of code quality and searching for vulnerabilities
- cleaning up and improving documentation, comments and code
- assistance during development
- double-checking PRs and commits after human review
- additional security analysis of PRs via Codex Security
AI is not used for:
- writing entire code
- "vibe code" approach
- code without line-by-line verification by a human
- code without tests
So AI is just an assistant and a tool for developers to increase productivity and ensure code quality. The work is done by developers.
Moreover, it's important to note that we do not differentiate between bad human code and AI vibe code. There are strict requirements for any code to be merged to keep the codebase maintainable.
Even if code is written manually by a human, it's not guaranteed to be merged. Vibe code is not allowed at all and all such PRs are rejected by default (see [contributing guide](https://databasus.com/contribute)).
The engineering safeguards behind these rules (CI, static analysis, dependency scanning, test coverage and vulnerability response) are documented in [Security & reliability engineering](#-security--reliability-engineering) above.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`databasus/databasus`
- 原始仓库:https://github.com/databasus/databasus
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+47
View File
@@ -0,0 +1,47 @@
# ADR-NNNN: <short, present-tense decision title>
- **Status:** Proposed | Accepted | Deprecated | Superseded by [ADR-XXXX](./XXXX-...)
- **Date:** YYYY-MM-DD
- **Deciders:** <names / roles>
- **Tags:** <area, e.g. backend, backups, security>
## Context
What problem are we solving and why now? Describe the forces at play: business goals,
technical constraints, prior decisions, and anything an outside reader needs to
understand the trade-offs. State assumptions explicitly and flag the shaky ones.
## Decision
The decision in one or two sentences, written as a present-tense statement of fact
("We use X to do Y"). Follow with the specifics: what changes, what stays, scope
boundaries, and the contract this establishes for callers.
## Alternatives considered
For each option, give the shape and the reason it lost. Be concrete — a rejected
alternative without a reason is just noise.
- **Option A — <name>.** Brief description. Rejected because <reason>.
- **Option B — <name>.** Brief description. Rejected because <reason>.
- **Do nothing.** What breaks or stays painful if we keep the status quo.
## Consequences
### Positive
- What gets easier, faster, safer, or cheaper.
### Negative
- New constraints, costs, complexity, or risks we're accepting.
### Neutral / follow-ups
- Work this unlocks or forces later (migrations, deprecations, doc updates,
monitoring, rollout plan).
## References
- Related ADRs: [ADR-XXXX](./XXXX-...)
- Issues / PRs / design docs / external links
+73
View File
@@ -0,0 +1,73 @@
# ADR-0001: Ship as a Docker image
- **Status:** Accepted
- **Date:** ~2024-06-01
- **Tags:** deployment, packaging, ops
## Context
Postgresus is a system for backup and restore operations.
It require for work:
- a Go backend (Gin + GORM) with an embedded scheduler;
- a React frontend, served as static assets;
- PostgreSQL tools (`pg_dump`, `pg_restore`, `pg_receivewal`) required for backup and restore operations.
We need to decide what the deploy unit is.
## Decision
We ship as a published `docker-compose.yml` with two services: the Postgresus
app image (backend + frontend + every supported `pg_dump`/`pg_restore`
version) and a PostgreSQL service for internal state. The full install
is always:
```bash
docker compose up -d
```
against our compose file. We don't publish standalone binaries, OS packages,
a single-image `docker run` flow or hosted SaaS.
## Alternatives considered
- **A single static binary** (Go binary with the frontend embedded via
`embed.FS`) plus PostgreSQL available on the host.
## Consequences
### Positive
- **Backend and frontend in one unit.** The frontend ships as static files
served by the Go process. No reverse proxy needed, no version skew between
API and UI.
- **One deploy unit, one upgrade path.** Migrations, config validation and
the embedded scheduler all live in the app container; internal state lives
in the sibling PostgreSQL service. `docker compose pull && docker compose
up -d` is the entire upgrade procedure; state lives in named volumes so
rollback is a tag change.
- **Trivial install.** `docker compose up -d` works on any host with Docker.
No language toolchains, no `apt install postgresql-client-16`, no PATH
games.
- **Cross-OS.** Same image works on Linux, macOS and Windows.
- **Predictable runtime.** The exact `pg_dump` used for a backup is the one
installed during image build when we install all PostgreSQL versions.
- **Subjectively, it's just nice to work with.** This isn't a serious argument
on its own, but it matters in a self-hosted product where the install
experience *is* the first-impression UX.
### Negative
- **Hard dependency on Docker.** Users who can't or won't run Docker (some
shared-hosting environments, philosophical preferences) aren't served. We
accept this — the target user already has Docker.
- **Big image, slow first pull.** Carrying multiple PostgreSQL versions makes
the image meaningfully larger than a Go binary would be. Multi-stage builds
and careful layer ordering help, but the first pull is still slower than
downloading a binary. Subsequent upgrades benefit from layer reuse.
- **Slow CI builds.** Multi-arch builds with several engines and several
major versions per engine take longer than `go build`. Acceptable for the
team; visible on release day.
- **"I just want a binary" friction.** Some users prefer a systemd unit or a
native package. We're explicitly not catering to that — the maintenance
cost of a second distribution channel outweighs the UX win.
@@ -0,0 +1,53 @@
# ADR-0002: Built-in PostgreSQL instead of SQLite
- **Status:** Accepted
- **Date:** ~2024-06-01
- **Tags:** storage, database, ops
## Context
Postgresus needs an internal datastore for its own state (schedules, backup
metadata, users). We need to decide what that datastore is and how we ship
it.
## Decision
We use PostgreSQL as the internal datastore and ship it as a second service
in the `docker-compose.yml` alongside the Postgresus app.
## Alternatives considered
- **SQLite** as an embedded datastore — no separate service, single file on
disk.
- **Full PostgreSQL as a sibling compose service** (chosen).
- **Letting the user choose between SQLite and PostgreSQL.** Rejected: a
single supported storage engine keeps the test matrix and support burden
small.
## Consequences
### Positive
- **Remote access if needed in the future.** PostgreSQL can be exposed over
the network without changing the storage engine.
- **Heavy writing if needed in the future.** PostgreSQL handles concurrent
writers without the single-writer limitation of SQLite.
- **More complex toolchain if needed in the future.** Extensions, replication
and richer query features are available when we need them.
- **We can connect the system to an external PostgreSQL.** Same engine means
swapping the bundled instance for a managed one is a config change, not a
rewrite.
- **Subjective experience.** SQLite tends to cause problems as a project
grows. Personally I (Rostislav) have had many issues with remote access,
backups and multiple writers. SQLite is fine for the current scope, but
choosing the heavier option up front avoids a forced migration later.
### Negative
- **Separate service to run and operate.** SQLite would have been a single
file inside the app container; PostgreSQL is a second compose service with
its own image, volume and lifecycle. Acceptable cost for the upside above.
- **More complicated to back up right now.** Backing up a live PostgreSQL
instance is more involved than copying a single SQLite file.
- **Extra CPU and RAM usage.** Running a PostgreSQL process adds overhead,
but it is small enough to be affordable on the target hosts.
@@ -0,0 +1,51 @@
# ADR-0003: Embed PostgreSQL in the image instead of a separate service
- **Status:** Accepted
- **Date:** 2025-07-21
- **Tags:** storage, database, packaging, ops
## Context
[ADR-0002](0002-build-in-postgresql-instead-of-sqlite.md) chose PostgreSQL as
the internal datastore and shipped it as a second `docker-compose` service
next to the app. In practice the two-service layout turned out to be heavier
to operate than expected: users have to manage a compose file, two images,
two volumes and the dependency between them, and the project can no longer
be installed with a plain `docker run`. Moreover, users do not upgrade
their PostgreSQL with Postgresus upgrade.
We need to decide whether to keep the sibling service or fold PostgreSQL into
the app image.
## Decision
We embed PostgreSQL directly into the Postgresus image and run it as a
process inside the same container as the app. The install flow goes back to
a single `docker run` (or a one-service compose file).
## Alternatives considered
- **Keep PostgreSQL as a sibling compose service** (previous design).
- **Embed PostgreSQL in the app image** (chosen).
## Consequences
### Positive
- **Single-container install.** `docker run` is enough — no compose
file required, no service ordering, no inter-service networking to
explain.
- **Easier to manage.** One image, one volume, one lifecycle. Upgrades,
backups of the install itself and rollbacks all act on a single unit.
- **More predictable environment.** The exact PostgreSQL version, config and
data directory layout are pinned by the image — users can't accidentally
point the app at an incompatible external instance or skew versions
between the two services.
### Negative
- **No ability to connect to a remote PostgreSQL.** The internal datastore
is now tied to the image; pointing Postgresus at a managed or external
PostgreSQL for its own state is no longer supported. Backups still target
arbitrary external PostgreSQL instances — this only affects Postgresus's
own state store.
@@ -0,0 +1,76 @@
# ADR-0004: Stream backup and restore data chunk-by-chunk with backpressure
- **Status:** Accepted
- **Date:** 2025-12-05
- **Tags:** backups, storage, performance, reliability
## Context
The original backup pipeline ran `pg_dump` and pushed its output to the
configured storage backend (S3 or local) as a single in-process flow,
accumulating the unsent tail of the dump in memory. With a single backup
running at a time this was fine.
Once real users started backing up 510 databases in parallel, the host ran
out of memory. The cause was the same on every install: the producer
(`pg_dump`) was consistently faster than the consumer (upload to storage),
so the difference accumulated in RAM. A slow storage backend, a transient
network error or an S3 throughput cap made the situation strictly worse —
the pipeline kept reading from `pg_dump` with nowhere to send the bytes,
and a single stalled backup could OOM-kill the whole container along with
every other backup running next to it.
The same shape applies to restore: reading a backup faster than the target
PostgreSQL can ingest it would let the buffered tail grow without bound.
## Decision
All backup and restore data moves through the pipeline as a stream of
fixed-size chunks (illustrative ~8 MB; the exact size depends on the
operation and the storage backend). The producer (e.g. `pg_dump` stdout,
`pg_basebackup` stream or the storage downloader during restore) and the
consumer (the storage uploader during backup, `pg_restore` or PostgreSQL
during restore) are coupled by **backpressure**: the producer cannot
enqueue chunk *N+2* until chunk *N* has been accepted by the consumer. A
small bounded in-flight window (illustrative ~16 MB per pipeline) caps
per-process memory regardless of any upstream/downstream speed mismatch.
**Backup and restore data does not land on the Postgresus host's disk.**
Bytes stream directly between PostgreSQL and the storage backend in both
directions. The Postgresus container's local disk is not used as a staging
area for backup payloads.
**Exception — operations that genuinely require a materialised file.**
`pg_restore -j >1` cannot consume a pipe; it needs a real file on disk to
parallelise across workers. In that case Postgresus explicitly pre-checks
that the local disk has enough free space to hold the full backup and
refuses the operation with a clear error if it does not. The streaming
model remains the default; materialisation is an opt-in fallback gated by
a precondition check.
## Consequences
### Positive
- **Bounded per-pipeline memory.** Memory usage is capped by the in-flight
window, independent of the size of the dump or the speed of the storage
backend. A 500 GB database costs the same RAM as a 500 MB one.
- **Predictable parallelism.** N parallel backups consume N × window-size
of memory, not N × dump-size. Capacity planning becomes a straight-line
function of how many concurrent jobs the operator allows.
- **Slow storage degrades gracefully.** A slow, throttled or stalled
storage backend slows the producer instead of crashing the host. The
worst case is a long-running backup, not an OOM.
- **No local disk requirement for normal backup/restore.** Operators do
not need to provision Postgresus-host disk equal to their largest
backup, and S3-as-primary-storage deployments work without local scratch
space.
- **Backups larger than the host's free disk are supported.** Database
size is bounded by the storage backend's capacity, not by the
Postgresus container's filesystem.
## References
- [ADR-0001](./0001-use-docker-as-base.md) — Docker base image (defines
the single-container environment whose memory limits this decision is
written against).
@@ -0,0 +1,76 @@
# ADR-0005: Vendor PostgreSQL client binaries under `assets/` instead of installing them in the Dockerfile
- **Status:** Accepted
- **Date:** 2026-05-24
- **Tags:** packaging, ci, supply-chain, dockerfile
## Context
Postgresus needs the per-version client tools for every database engine it
supports — `pg_dump` / `pg_restore` / `pg_basebackup` for PostgreSQL,
`mysqldump` / `mysql` for MySQL and MariaDB, `mongodump` / `mongorestore` for
MongoDB. The original approach installed all of them at image-build time
straight from upstream apt repositories (PGDG, the MySQL/MariaDB apt repos,
MongoDB's apt repo), one `apt-get install` invocation per supported major.
This worked when only a couple of PostgreSQL versions were supported. Then
MySQL, MariaDB and MongoDB landed on top of PostgreSQL, and the supported
matrix grew to **18 engine/major combinations**:
- PostgreSQL: 12, 13, 14, 15, 16, 17, 18 (7 majors)
- MySQL: 5.7, 8, 9 (3 majors)
- MariaDB: 10, 11, 12 (3 majors)
- MongoDB: 4.2+, 5, 6, 7, 8 (5 majors)
At that size the build-time install model fell over:
- **CI time crossed 30 minutes** and kept climbing. Most of that time was
spent re-downloading the same `.deb` packages on every build because the
apt layers invalidated easily.
- **CI failed often** for reasons that had nothing to do with our code —
PGDG / MySQL / MariaDB / MongoDB mirror timeouts, transient apt errors,
DNS hiccups, GPG key churn. Each engine added its own apt repo, its own
signing key and its own way to flake. Every external dependency in the
build path is another way the build can fail.
- **The Dockerfile carried a lot of accidental complexity** — four sets of
repo setup, four sets of key imports, version pinning per engine,
multi-major installs and apt-cache cleanup.
We need to decide whether to keep installing clients from upstream apt
repositories at build time or vendor the binaries we actually use.
## Decision
We download the client binaries we need once across all supported engines,
commit them under `assets/` keyed by engine, major version and architecture
and `COPY` them into the image. The Dockerfile no longer talks to PGDG, the
MySQL / MariaDB repos, the MongoDB repo or any other external package
repository for database client tools.
## Consequences
### Positive
- **CI is dramatically faster and more stable.** The `COPY` of vendored
binaries is a cached layer that costs near-zero on rebuilds. The build no
longer downloads hundreds of megabytes of `.deb` packages on every run.
- **No external dependency in the build path.** PGDG outages, mirror
flakiness and key-rotation events stop breaking our builds. Builds are
reproducible from a clean checkout without network access to third-party
repositories.
- **Smaller supply-chain attack surface.** The build no longer fetches and
executes arbitrary versions of third-party packages resolved at build
time. The exact bytes that ship in the image are the exact bytes
committed to the repo and reviewable in a PR.
- **Smaller, simpler Dockerfile.** Repo setup, GPG key handling, pinning,
multi-version installs and apt-cache cleanup all go away. What's left is
a handful of `COPY` lines that say plainly what is in the image.
- **Version pinning is explicit and auditable.** Upgrading a client is a
visible commit that swaps the binary under `assets/`, not an opaque
`apt-get install` that resolves to whatever PGDG happens to serve that
day.
## References
- [ADR-0001](./0001-use-docker-as-base.md) — Docker base image (the
single-container environment whose Dockerfile this decision simplifies).
@@ -0,0 +1,63 @@
# ADR-0006: Distribute cloud load across primary and processing nodes
- **Status:** Deprecated (cloud edition discontinued, 2026-06; multi-node deployment vars `IS_PRIMARY_NODE` / `IS_PROCESSING_NODE` remain valid)
- **Date:** 2026-02-01
- **Tags:** cloud, deployment, networking, multi-node
## Context
The project required funding so we need to launch Cloud to invest in development.
In cloud mode Databasus faces two pressures that a single-node deployment cannot answer.
**Source-IP exposure.** Every backup opens an outbound connection from the Databasus host to the customer's external database. A single-node cloud install would expose one fixed IP to every tenant — a public target for attack.
**Bandwidth contention.** Backups can be arbitrarily large. One tenant pulling 20 backups of 1 TB each will saturate even a 10 Gbit/s uplink for a significant amount of time and starve every other tenant's backup or download down to a crawl. The instance-mode design assumes a single operator with predictable load; the cloud has many tenants with adversarial timing.
The instance (self-hosted) deployment does not have these pressures: one operator runs one node against their own databases on their own network.
## Decision
Cloud deployments run as a cluster of nodes built from the **same binary**, differentiated by two orthogonal axes.
### Application roles
Roles are controlled by environment flags:
- `IS_PRIMARY_NODE=true` — runs schedulers, migrations, the backup and restore registries that distribute work over Valkey pub/sub. One node per Databasus installation.
- `IS_PROCESSING_NODE=true` — runs the backup and restore worker loops that pick up jobs from Valkey and stream bytes between customer databases and storage. N nodes per Databasus installation.
Both flags can be set on the same node. Instance mode is exactly this case: `IS_MANY_NODES_MODE=false` defaults both to `true` so one container handles everything.
### Network exposure
Network exposure is controlled by infrastructure, not by the binary:
- **API-facing nodes** sit behind CloudFlare with port 4005 published through a reverse proxy. CloudFlare absorbs DDoS on the API surface.
- **Processing nodes** do not publish port 4005 in their Docker config and are not added to any reverse-proxy upstream. They reach the rest of the cluster only through the internal Valkey channel. They are not reachable from the public internet so they cannot be DDoSed on the API surface.
Inter-node coordination flows through Valkey pub/sub — primary publishes assignments on `backup:submit` and `restore:submit`; processing nodes subscribe and report completions on the corresponding completion channels. Nodes never call each other over HTTP.
### Bandwidth fairness
Each node has a configured `NODE_NETWORK_THROUGHPUT_MBPS` budget (default 125 MB/s ≈ 1 Gbit/s). The `BandwidthManager` reserves 75% of the budget for data-moving operations and splits it equally across the active ones in real time. Ten parallel operations on a 1 Gbit/s node get ~100 Mbit/s each. Starting or finishing an operation recomputes every active rate so no single user can hog the pipe.
A per-user lock caps each user to one in-flight operation of each kind so one tenant cannot fan out an unbounded number of parallel jobs against the node.
## Consequences
### Positive
- **Many source IPs instead of one.** Processing nodes are deployed across multiple hosts and regions. A customer sees one of N IPs per backup so no single IP carries the whole fleet's reputation.
- **API and backup capacity scale independently.** Adding API capacity costs a small node behind CloudFlare. Adding backup capacity costs a gigabit-class processing node with no inbound exposure.
- **Fair sharing under load.** No tenant can starve another by starting a huge download. The split is computed per active session so the worst case for any user is a slow download, not a failed one.
- **One binary, one image.** Roles are environment configuration so deploys, tests and rollbacks operate on a single artifact.
### Negative
- **The deployment contract lives outside the code.** "Processing node has no port mapping and no proxy upstream" is enforced by ops, not by the binary. A misconfigured deploy can expose a processing node's API. The contract belongs in the deployment runbook and infra-as-code.
- **CloudFlare protects only inbound API traffic.** Backup throughput is outbound from processing nodes directly to customer databases and storage so it never traverses CloudFlare. Flooding a processing node's uplink is still possible; the mitigation is gigabit-class nodes and the per-node bandwidth budget.
## References
- [ADR-0004](./0004-focus-on-streaming-chunk-by-chunk-with-backpressure.md) — chunk-streaming with backpressure (the memory-safety foundation this scheduling sits on top of).
@@ -0,0 +1,41 @@
# ADR-0007: Enforce read-only database access and encrypted backups in cloud
- **Status:** Deprecated (cloud edition discontinued, 2026-06)
- **Date:** 2026-02-01
- **Tags:** cloud, security, backups, access-control
## Context
Databasus is a backup tool — by definition it holds the keys to the most valuable data a customer has. We treat that responsibility seriously and invest in security at every layer.
- **Application.** Secret encryption at rest, scoped credentials per tenant, audit logging on every privileged action. Every PR runs through CodeQL, Trivy, Dependabot, gitleaks, semgrep, CodeRabbit and Codex Security. Security review is mandatory on changed code.
- **Infrastructure.** Network isolation per [ADR-0006](./0006-cloud-load-and-traffic-distribution-between-nodes.md), processing nodes with no public ingress, CloudFlare in front of the API, hardened Docker images with non-root runtime, GitHub Actions pinned to commit SHAs, regular base-image refresh.
- **Access.** Least-privilege IAM on storage backends, short-lived tokens, MFA on operator accounts, no shared production credentials.
That stack is genuinely strong and we keep raising it. The chance of a breach against any one layer is small.
It is not zero. A kernel 0-day, a TLS-library CVE, etc. — any of these can land on a Monday morning and cut through layers we trusted on Sunday night. AI offensive tooling like Claude Mythos now chews through systems that were considered hardened a year ago, compressing the gap between disclosure and weaponisation to hours. On a long enough timeline some external dependency we do not own fails.
We add one more layer that does not depend on any of the above holding: we structurally remove anything Databasus could do that would harm a customer. Even in the scenario where every other defence is bypassed, the attacker finds nothing useful at the end of the chain.
## Decision
Cloud mode enforces two hard rules at the application level.
**Read-only database access.** Cloud customers can only connect Databasus to a database role with the read privileges sufficient to run the backup tool for that engine — `SELECT` plus the engine-specific reads needed by `pg_dump` / `mysqldump` / `mongodump`. Roles with write, DDL or superuser privileges are rejected at connection-setup time with a clear error. The check runs before the first backup is scheduled and is repeated on every credential rotation.
**Encrypted backups only.** Every backup artifact produced in cloud mode is encrypted on the processing node before it leaves the process. "Backup without encryption" is not a supported option in cloud — the toggle does not exist in the cloud UI or API.
Self-hosted instance mode keeps both options open. The operator owns the security perimeter and may choose write access or plain backups for their own valid reasons.
## Consequences
### Positive
- **A Databasus compromise cannot corrupt customer data.** Even with full credentials in hand the attacker can only read. There is no write path through Databasus to customer production — the worst case is structurally bounded, not policy-bounded.
- **The 0.0001% case has a defined ceiling.** No single defence has to be perfect because each layer caps what the next failure can do. Weak independent guarantees compose into a strong one.
- **One answer to "can I give a write user?"** Cleaner support story, no edge-case configuration to debug.
## References
- [ADR-0006](./0006-cloud-load-and-traffic-distribution-between-nodes.md) — cluster topology and network isolation for cloud nodes (the perimeter this decision layers on top of).
@@ -0,0 +1,109 @@
# ADR-0008: Use PostgreSQL 17 native physical backups with mandatory WAL summary
- **Status:** Accepted
- **Date:** 2026-05-26
- **Tags:** backups, postgresql, physical, pitr
## Context
Databasus needs physical backups with incremental support and WAL streaming
to deliver PITR. Three implementation paths exist for that pipeline today:
- **Custom block-level incremental** — read `$PGDATA` directly, hash blocks,
store block-level diffs in a proprietary format. This is what pgBackRest
(`--repo-block` since 2.46) and WAL-G do. The cost is a full backup format
with bundle / pagefile / manifest code, block-checksum tracking on the
client side and per-engine retention machinery.
- **Pre-PG-17 incrementals via WAL replay** — take periodic FULLs and treat
`FULL + raw WAL replay since stop_lsn` as a substitute for incremental.
The "incrementals" don't exist as artifacts; the user pays for them with
replay time on every restore.
- **PG 17 native incremental** — `pg_basebackup --incremental=` against the
previous backup's manifest, server-side WAL summarizer tracking changed
blocks, `pg_combinebackup` to materialise a chain into a runnable PGDATA.
Block-level granularity is computed by PostgreSQL itself from WAL summary
files in `$PGDATA/pg_wal/summaries/`.
## Decision
We use **PostgreSQL 17's native physical backup stack** for every physical
flow Databasus runs: `pg_basebackup` for FULLs, `pg_basebackup --incremental=`
for incrementals against the parent backup's manifest, `pg_receivewal` for
WAL streaming and `pg_combinebackup` on restore. No custom block-level
format, no pgBackRest-style block hashing, no WAL-G-style bundle code.
The decision has two hard rules attached:
**Physical backups refuse PG < 17.** The required binaries
(`pg_basebackup --incremental`, `pg_combinebackup`) and the required GUC
(`summarize_wal`) do not exist before PG 17. Connection setup refuses
non-17+ clusters with a clear error pointing at the logical path.
**`summarize_wal = on` is mandatory whenever incrementals are selected.**
For backup types `FULL_AND_INCREMENTAL` and `FULL_INCREMENTAL_AND_WAL_STREAM`,
the database-setup flow refuses to save the config until the upstream reports
`summarize_wal = on`. The UI surfaces the fix-it command
(`ALTER SYSTEM SET summarize_wal = on; SELECT pg_reload_conf();`) and a
"try again" button. `FULL_ONLY` skips the check — no INCRs ever spawn so
no summaries are needed.
We do not support `FULL + raw WAL replay` as a substitute for incrementals.
If the user picked incrementals the system delivers incrementals — refusing
to start without summaries is the gate that keeps the runtime in a single
reliable shape.
## Alternatives considered
- **Implement our own block-level incremental** (pgBackRest / WAL-G shape).
Rejected because PostgreSQL 17 ships the same capability natively. Writing
our own would mean owning bundle code, pagefile code, manifest code and a
separate backup format — large surface area to maintain and a separate
class of bugs (delta math, block-checksum drift, manifest format
versioning) — for no functional gain over what `pg_basebackup --incremental`
already delivers.
- **Accept incrementals without `summarize_wal = on` by falling back to
full WAL replay between FULLs.** Rejected because the fallback is
unreliable (no per-cycle manifest-level integrity check) and catastrophic
for RTO (replay of every WAL segment since the last FULL on every
restore). The user asked for incrementals — silently delivering "FULL
every cycle with slow restore" is worse than refusing the config.
- **Support physical backups against PG ≤ 16 via the legacy
`pg_basebackup` + WAL-replay path.** Rejected for the same RTO and
reliability reasons above, plus the per-version branching cost across the
whole physical pipeline. PG ≤ 16 users stay on logical backups, which
cover the same data with a different operational profile.
## Consequences
### Positive
- **Smaller code surface.** No bundle / pagefile / block-checksum
machinery to maintain. The physical pipeline orchestrates PG-native
binaries (`pg_basebackup`, `pg_receivewal`, `pg_combinebackup`) and
records artifacts in storage; the format itself is PG's responsibility.
- **Battle-tested incremental math.** Block-level change tracking lives in
PostgreSQL's WAL summarizer — a server-side feature exercised by
upstream's test suite and the broader PG ecosystem. We inherit that
testing without paying for it.
- **Forward compatibility.** Improvements to `pg_basebackup` /
`pg_combinebackup` / summarizer in PG 18+ accrue to us automatically as
the bundled binaries roll forward.
- **Single runtime shape.** Mandatory `summarize_wal = on` collapses
"what happens when summaries are missing on the source" into one
failure mode (`SUMMARIZER_OFF` ⇒ refuse) instead of a fallback
matrix.
### Negative
- **PG 17+ requirement for physical.** Operators on PG 1216 cannot use
physical backups in Databasus and stay on `pg_dump`. This is a real
exclusion — PG 16 was a current major as of late 2024 — but the trade-off
is deliberate: we built the physical pipeline on a feature set that did
not exist before PG 17, and back-porting it is the work we explicitly
chose not to do.
- **Managed PG that forbids `ALTER SYSTEM`.** On a managed PG where
`summarize_wal` cannot be flipped via SQL (RDS / Cloud SQL / Azure DB
parameter-group flows), the user must change the GUC through the
provider's UI before Databasus will save an incremental config. We
surface the command for self-managed clusters and the parameter name for
managed ones; we do not silently downgrade to FULL-only.
@@ -0,0 +1,96 @@
# ADR-0009: Run physical backups remotely instead of via an agent on the database host
- **Status:** Accepted
- **Date:** 2026-05-26
- **Tags:** backups, postgresql, physical, architecture, deployment
## Context
Physical backups built on PG 17 native (per
[ADR-0008](./0008-why-pg17-native-backups-with-mandatory-wal-summary.md))
can be driven in two shapes:
- **Agent-mode.** Install a Databasus-shipped binary on the same host as the
user's PostgreSQL. The agent reads `$PGDATA` / `pg_wal` locally, runs
`pg_basebackup` against `localhost`, ships artifacts to storage on the
user's behalf and coordinates with the central Databasus over an outbound
channel. This is the shape pgBackRest / Barman use, and the shape WAL_V1
used inside Databasus before it was deleted.
- **Remote-mode.** The Databasus host itself runs `pg_basebackup` and
`pg_receivewal` against the user's PG over the wire, using the standard
replication protocol. The user's host runs nothing of ours. Setup on the
user's side is one role with `REPLICATION` plus a `replication`-line in
`pg_hba.conf`.
We tried agent-mode in WAL_V1 and removed it.
## Decision
All Databasus physical backup flows run **remotely** from the Databasus
host against the user's PostgreSQL via the replication protocol. Databasus
never ships a per-host agent. For users whose PG is not reachable from the
Databasus host directly, the supported escape hatch is an **SSH tunnel**
from Databasus to a jump host inside the user's perimeter — same backup
code path, transport wrapped.
Three reasons drive the choice:
**1. Simpler setup for the user.** No binary install on the database host,
no init script, no agent upgrade cycle, no host-level capacity reservation.
The end-to-end setup is: create a role with `REPLICATION` privilege, add
one `replication` line to `pg_hba.conf` (or grant `rds_replication` on
managed PG), reload, paste creds into Databasus. Most managed PG providers
forbid running anything on the DB host anyway, so an agent shape would
either exclude managed PG entirely or fork into a remote-mode branch for
that case — which is the branch we now use universally.
**2. Less code and a cleaner test surface.** A single execution path —
"Databasus host runs `pg_basebackup` / `pg_receivewal` against a remote
endpoint" — keeps the physical pipeline identical to how logical backups
already work. No second binary to build, version, sign and ship; no agent
self-update protocol; no E2E matrix that stands up a real agent next to
a real PG and exercises the cross-process protocol in CI. The CI matrix for
physical backups is the same matrix we already run for logical: spawn a PG
container, point Databasus at it, run the flow.
**3. Privacy and closed-network deployments are already covered.**
Databasus is normally deployed inside the user's own perimeter — the
remote connection from Databasus to PG crosses a private network the user
controls, not the public internet. For the minority of deployments where
PG sits behind a stricter inner perimeter than Databasus itself, an SSH
tunnel from Databasus to a jump host inside the PG perimeter delivers the
same security profile as an agent would (no PG port exposed beyond the
inner perimeter, traffic encrypted, authentication via the user's own SSH
infrastructure) while keeping the backup code path unchanged. This covers
the long tail without forking the pipeline.
## Alternatives considered
- **Agent on the user's database host.** Rejected for the reasons above:
doubles the binary / release / CI surface, excludes managed PG by
construction (or forks into remote-mode anyway), and offers no
functional capability that remote-mode + SSH tunnel does not already
deliver. The historical WAL_V1 implementation is the concrete evidence —
it carried agent-token plumbing, a separate `agent/backup/` package, a
scheduler bail-out branch and per-method `if BackupType == WAL_V1
{ not supported }` shims through the codebase, all of which dissolved
cleanly when remote-mode replaced it.
## Consequences
### Positive
- **One code path for physical backups.** The remote-execution shape
matches logical backups and the verification agent's restore flow, so
shared infrastructure (node assignment, dead-node detection, bandwidth
fairness, encryption, storage abstraction) carries over without
per-shape branching.
- **No agent release pipeline.** No second artifact to version, sign,
publish, document or auto-update across the installed base. The
Databasus binary is the only thing the user runs.
- **Managed PG works out of the box.** RDS / Cloud SQL / Azure DB ship
with the replication protocol exposed and host-side installs forbidden.
Remote-mode fits that shape natively; agent-mode would not.
- **Single test surface.** Physical CI exercises the same
"spawn PG container, point Databasus at it" matrix as logical, plus an
SSH-tunnel variant. No cross-process agent protocol to fuzz.
@@ -0,0 +1,37 @@
# ADR-0010: No support for custom tablespaces in physical backups
- **Status:** Accepted
- **Date:** 2026-05-26
- **Tags:** backups, postgresql, physical, tablespaces
## Context
Physical backups stream `pg_basebackup --pgdata=- --format=tar` directly into storage with zero local disk (see [ADR-0004](./0004-focus-on-streaming-chunk-by-chunk-with-backpressure.md)). `pg_basebackup` writes one tar to stdout for the main data directory plus one tar per custom tablespace. Since stdout is a single stream `pg_basebackup` refuses `-D -` whenever any tablespace other than `pg_default` and `pg_global` exists.
Two paths could preserve physical backups for those clusters: a bounded local staging buffer that catches the multi-file tar output and multipart-uploads each file in flight; or a custom implementation of the PG BASE_BACKUP replication protocol that bypasses `pg_basebackup` entirely (the path pgBackRest and WAL-G take). Both require significant new architecture.
Custom tablespaces are a minority configuration even among production PG operators we target. Managed PG (RDS, Aurora, Cloud SQL, Azure DB) restricts or forbids them. Kubernetes operators (CloudNativePG, Zalando, Crunchy) deploy one PVC per instance. Modern self-hosted PG runs on a single large NVMe where ZFS or LVM has replaced the I/O-tiering motivation tablespaces historically served.
The remaining slice is either legacy installations carrying a tablespace decision made years ago, or specialty workloads like Greenplum (which ships its own `gpbackup`) and TimescaleDB chunk-tiering (which sits on pgBackRest / WAL-G already and is not part of our target audience).
## Decision
Physical backups refuse clusters with any tablespace outside `pg_default` and `pg_global`. The refusal is a pre-flight check (`CheckNoCustomTablespaces` in `timeline.go`) that runs before any backup row is created. The scheduler logs `physical_backup_rejected_unsupported_tablespaces` with the offending spcnames and sends one notification per database per 24 hours. The user keeps the logical backup path which handles tablespaces transparently through `pg_dump`'s SQL emission.
This is a permanent decision not a roadmap item. We will not revisit it without a concrete user request carrying real numbers — cluster size, tablespace count and willingness to provision staging disk.
## Alternatives considered
- **Bounded local staging.** Reserve a local volume on the Databasus host, let `pg_basebackup -D <dir>` write multiple tars there, watch with inotify, multipart-upload to storage as files grow and delete after upload. Rejected because it doubles pipeline complexity (loses the kernel-pipe back pressure that comes free with stdout streaming), introduces an ENOSPC failure mode that depends on relative speed of write and upload and serves an audience that has not asked for it.
- **Own PG replication protocol (pgBackRest / WAL-G shape).** Parse `BASE_BACKUP` replication messages directly through `pglogrepl`, emit our own multi-tablespace archive format. Rejected as the explicit anti-direction in [ADR-0008](./0008-why-pg17-native-backups-with-mandatory-wal-summary.md) — we delegate the backup format to PG precisely so we do not own a parallel implementation of PG-internal structure that has to track every major release.
## Consequences
### Positive
- Streaming with kernel-pipe back pressure stays the universal physical-backup pipeline. No staging-disk mode to test, document or fail at runtime.
- The metadata sidecar drops `Tablespaces` and `TablespaceLocation`. Restore-time tablespace mapping code is never written. A class of restore failures ("the original mount path no longer exists on the target host") is removed by construction.
### Negative
- Users with custom tablespaces who want physical / PITR get a clean refusal and must either drop the tablespaces or stay on logical backups. We estimate this affects at most 515% production setups.
+38
View File
@@ -0,0 +1,38 @@
# ADR-0011: No partial WAL uploads to cloud storage
- **Status:** Accepted
- **Date:** 2026-05-27
- **Tags:** backups, postgresql, physical, wal, pitr
## Context
`pg_receivewal` streams WAL from the source cluster to Databasus in real time, writing into a `.partial` file that grows as the source writes. When the segment fills (default 16 MB) PostgreSQL rotates it and `pg_receivewal` renames the file to its final name. The plan's invariant — `.partial never uploaded` — means we only ship fully-rotated segments to cloud storage.
That bounds cloud-side RPO by `wal_segment_size`. On an active OLTP cluster a segment fills in seconds and the gap is invisible. On an idle cluster the same 16 MB might accumulate over hours or days, and a source crash in that window can lose everything since the last completed rotation.
PostgreSQL has a native knob for this exact problem: `archive_timeout`. Setting it on the source forces a WAL switch every N seconds regardless of fill, capping the size of any unrotated tail. Every mainstream physical backup tool (pgBackRest, WAL-G, Barman) takes the same shape we do — upload completed segments only, document `archive_timeout` as the RPO control.
## Decision
We upload completed WAL segments and nothing else. `.partial` stays a transient file on the Databasus host and never reaches storage.
Customers who need a tighter RPO than `wal_segment_size` set `archive_timeout` on their source cluster (or the parameter-group equivalent on managed PG). The UI surfaces this as a one-line hint next to the physical-backup config: the SQL command for self-managed, the parameter-group key for RDS / Cloud SQL / Azure.
Databasus does not call `pg_switch_wal()` on the source. RPO control lives where the data lives — on the customer's cluster.
## Alternatives considered
- **Partial-tip channel.** Snapshot the current `.partial` every N seconds, compress, encrypt, upload under a tip key that gets overwritten on each snapshot. Delete the tip when the underlying segment rotates and uploads normally. Rejected because it doubles the WAL pipeline: a new `physical_wal_partial_tips` table, a snapshotter goroutine alongside the existing streamer, lifecycle handover between tip-deleter and snapshotter, restore-side merge logic, plus ongoing storage PUT cost (~$8/day per 100 DBs at a 5-second interval on S3). None of pgBackRest, WAL-G or Barman ship this — we'd be inventing a non-standard mechanism for an effect `archive_timeout` already delivers.
- **Databasus-driven `pg_switch_wal()` on a timer.** Force rotation from Databasus every N seconds without touching the customer's GUC. Rejected because it mutates source-cluster state beyond what `REPLICATION` privilege normally implies, masks the real WAL behaviour from the operator (they see "RPO 5s" without understanding why, then get a surprise after failover or when Databasus is paused) and creates two competing rotation cadences (ours plus whatever the operator may set later).
## Consequences
### Positive
- The WAL pipeline stays a one-direction conveyor: `pg_receivewal` produces completed segments, we ship them. No mutable artifacts in storage, no tip lifecycle, no merge step at restore time. Every row in `physical_wal_segments` is immutable once `file_name` is non-NULL.
- RPO control sits with the customer, where the storage cost of tighter RPO (more segments per day, each mostly zero-padded) is also paid. We don't make budget decisions for them.
### Negative
- Cloud-side RPO on an idle cluster with default settings can be arbitrarily large. Users who don't read the help text and run an idle DB will be unpleasantly surprised after a source-side disaster.
- Managed PG users who can't set `archive_timeout` through their provider's parameter group have no escape hatch. In practice every major managed PG (RDS, Cloud SQL, Azure DB) exposes the parameter, but we accept being blocked on the rare provider that doesn't.
@@ -0,0 +1,146 @@
# ADR-0012: pg_basebackup streams a single tar to stdout; backup_manifest is reconstructed on Databasus
- **Status:** Accepted
- **Date:** 2026-05-27
- **Tags:** backups, postgresql, physical, pg_basebackup, compression, manifest
## Context
[ADR-0008](./0008-why-pg17-native-backups-with-mandatory-wal-summary.md)
commits Databasus to driving PG's native physical-backup binaries;
[ADR-0009](./0009-why-remote-physical-backups-instead-of-agents.md)
commits to running them remotely from the Databasus host. Within those
constraints the FULL/INCR executor must satisfy three things at once:
1. **No local staging.** The host serves many sources; staging a 3 TB
database per concurrent backup would need tens of TB of disk that
sits cold between backups.
2. **One self-contained artifact** per backup — base data plus the WAL
needed to make it consistent — ready for one-shot restore.
3. **A `backup_manifest` alongside every artifact.**
`pg_basebackup --incremental=<parent_manifest>` needs the parent
manifest to find changed blocks; no manifest means no incrementals,
which ADR-0008 forbids.
`pg_basebackup` exposes its features through CLI flags, and the wrong
combination silently breaks one of these. This ADR records the chosen
flag set — and the one conflict (server-side compression vs. embedded
manifest) that forces us to rebuild the manifest ourselves.
## Decision
Invoke `pg_basebackup` with
`--pgdata=- --format=tar --wal-method=fetch --compress=server-zstd:5 --no-manifest`
(INCR adds `--incremental=<parent_manifest>`). The compressed stdout is
teed on Databasus into two independent branches: one uploads the bytes
to storage as-is, the other decompresses and walks the tar to
reconstruct `backup_manifest`. Each flag earns its place:
**`--format=tar --pgdata=-`** streams one tar to stdout, piped straight
into the storage `SaveFile(io.Reader)`. `--format=plain` would need a
real disk path (breaks no-staging) or ~1 TB of uncompressed staging.
ADR-0010 already forbids custom tablespaces, so there is exactly one
tar — no concatenation on our side.
**`--wal-method=fetch`** collects WAL through the same replication
connection at end-of-backup and inlines it as `pg_wal/...` tar entries,
keeping the single-stream invariant. `--wal-method=stream` needs a
second output channel that stdout-tar mode refuses. Fetch's one
weakness — the source must retain WAL until end-of-backup — is covered
by the per-backup replication slot (`backup_slot.go`), which pins WAL
for the backup's duration.
**`--compress=server-zstd:5`** — on TB-scale databases the
PG→Databasus link dominates, and compressing on the source sends ~1/3
of the bytes:
| Link | client-zstd | server-zstd |
|----------------|-------------|-------------|
| 1 Gbit/s LAN | ~7 hours | ~2.3 hours |
| 100 Mbit/s WAN | ~3 days | ~22 hours |
More PG-side CPU for far less wire time is the right trade on
multi-hour windows, so server-side compression is mandatory here.
Because it depends on the source build, the codec degrades
`server-zstd:5 → server-gzip:6 → none`.
**`--no-manifest` + reconstruction.** Server compression plus
tar-to-stdout makes pg_basebackup refuse to embed the manifest
(`cannot inject manifest into a compressed tar file`): the stream is
already compressed when bytes leave PG, so it cannot re-open it to
append the trailing manifest entry, and there is no side-channel flag
under `--pgdata=-`. `--no-manifest` removes the conflict but takes
manifests away, which breaks incrementals — so we rebuild it. Every
manifest field is derivable on Databasus from the tar stream plus a
couple of values already on hand (the cluster's system identifier and
the backup's LSN range), targeting PG's version-2 format.
(`--manifest-checksums` is itself incompatible with `--no-manifest`, so
it is omitted; our serializer fixes the per-file checksum at SHA-256.)
We don't aim for byte-identity with PG's own manifest — only a valid,
self-consistent one that truthfully describes the artifact.
`pg_verifybackup` validates that directly: pointed at the produced
compressed-tar artifact plus its reconstructed sidecar (`-m`) it
recomputes every checksum and size, the file set and the manifest's
own SHA-256, so it passes only if the reconstruction is correct — and
catches PG format-drift in CI before production. Reading a compressed
tar needs **PG 18**'s `pg_verifybackup` (tar-format support landed in
PG 18), a test-only tool dependency even though the executor drives
PG 17.
The reconstructed manifest is a separate sidecar object alongside the
artifact, encrypted independently with its own key material. INCR
downloads the sidecar, materialises it as a temp file and passes it to
`--incremental=`.
## Alternatives considered
- **Stay on `--compress=client-zstd:5`.** Zero new code. Rejected:
7+ hours per FULL on gigabit LAN is not viable as fleets grow into
TB-scale databases. WAN is unusable.
- **Local staging (`--pgdata=<dir> --compress=server-zstd:5`).** PG
emits its own manifest as a side file `backup_manifest`; no
reconstruction needed. Rejected because it requires ~1 TB of
staging disk per concurrent backup — exactly the constraint the
streaming pipeline was designed to avoid. A backup host serving 10
sources would need tens of TB of staging-only disk sitting cold
between backups.
- **Speak the replication protocol directly (pgMoneta-style).** Send
`BASE_BACKUP COMPRESSION 'server-zstd:5' MANIFEST 'yes'` over a
libpq replication connection; manifest arrives as a separate
CopyData stream byte-exact from PG. Rejected because it requires
re-implementing pg_basebackup's full surface — multi-tablespace
handling, WAL fetch trailer, error recovery, incremental protocol —
and maintaining that against every PG release. The single benefit
(no reconstruction) costs 2-3 KLOC of replication-protocol code
carried for our use only. The spirit of ADR-0008 — use PG's native
binaries, don't fork them — applies; if we ever need features
pg_basebackup cannot provide (mid-stream heartbeats for WAN,
structured progress events) this is the path we revisit.
## Consequences
### Positive
- PG→Databasus traffic compressed ~3x. 1-3 TB backups complete in
hours, not days; WAN deployments become viable.
- Self-contained tar artifact preserved (base + WAL + reconstructed
manifest sidecar).
- Incremental chain unblocked — the manifest sidecar is consumed by
`--incremental=` exactly as PG's own would have been.
- No new deployment requirement. pg_basebackup remains the workhorse
and keeps owning retry, error mapping and multi-tablespace handling
for free.
### Negative
- Manifest-reconstruction code (codec reader + tar walker + PG-format
serialiser) to maintain.
- Reconstruction-correctness risk against PG's manifest format,
mitigated by the `pg_verifybackup` gate (PG 18): a drifted
reconstruction fails in CI, not production.
- Net CPU on Databasus to decompress and SHA-256 the full backup
contents. Acceptable on a dedicated backup host; the levers are the
server-side zstd level and the manifest-path worker count.
@@ -0,0 +1,131 @@
# ADR-0013: Reuse one in-memory test database per version instead of one container per test
- **Status:** Accepted
- **Date:** 2026-06-08
- **Deciders:** Backend team
- **Tags:** backend, testing, ci
## Context
Our database tests run the same checks against many versions of each engine (PostgreSQL, MySQL,
MariaDB, MongoDB). We tried two setups before this one and both broke.
**Before — one big docker-compose.** We started every version of every engine up front and kept them
all running. That is ~50 containers alive at the same time and a 16 GB CI machine ran out of memory:
```
docker-compose up (everything, all the time)
postgres:12 .. postgres:18
mysql:5.7 .. mysql:8.4
mariadb:10.6 .. mariadb:12.0
mongo:4.0 .. mongo:8.0
──────────────────────────────
~50 containers alive at once → out of RAM
```
**Naive fix — a fresh container per test.** Every single test booted its own database and killed it
afterwards. A database cold-starts in 2060s and one package has 40+ tests, so the suite hit the
15-minute timeout:
```
test 1 → boot DB → run → kill DB
test 2 → boot DB → run → kill DB
... (40+ times per package) → too slow
```
We want both: fast tests and bounded memory, without tests stepping on each other.
## Decision
For each database version we boot one container, run all the test functions against it, then shut it
down before the next version:
```
boot postgres:16 (once)
├─ run test 1
├─ run test 2
└─ run test 3
shut down postgres:16
→ boot postgres:17 ...
```
Two more things make this fast and safe: the database files live in **RAM** (so each boot and restore
is quick) and the test packages run **in parallel** (so we get the speed back) while each one stays
isolated (so peak memory stays bounded).
- **One container per version, reused across the version's tests.** Each matrix package declares its
version list once and loops it with an outer `t.Run`. Inside that subtest it boots the server with
`StartPostgres` / `StartMysql` / … (`containers/{postgres,mysql,mariadb,mongodb}.go`), then runs the
former test functions as inner subtests. `StartXxx` registers `t.Cleanup`, which Go runs when the
version subtest returns — so the container is torn down before the next version boots and **only one
matrix container is alive per package at a time**. Each `go test` package is its own process, so its
containers belong only to it. The one rule for test authors: when you create a fixed-name object (a
table or user without a random suffix), start with `DROP ... IF EXISTS` — within a version the server
is reused and the subtests run one after another.
- **Database files in RAM.** Each engine's data dir is mounted on tmpfs `rw,size=512m` (size is pinned —
otherwise Docker reserves half the host RAM per container). Crash-safety is off because we throw these
servers away: Postgres `fsync=off / full_page_writes=off / synchronous_commit=off`; MySQL & MariaDB
`innodb-flush-log-at-trx-commit=0 / innodb-doublewrite=0 / sync-binlog=0 / skip-log-bin`. Files:
`containers/{postgres,mysql,mariadb,mongodb}.go`.
- **Run packages in parallel.** `go test -p=8` (`TEST_PARALLEL_WORKERS` in `backend/Makefile`). One
matrix container is alive per package, so peak memory stays around 1011 GB instead of the old
all-at-once that ran out of RAM.
### How parallel workers stay isolated
Eight packages run at the same time and share the same Postgres, Valkey and backup infrastructure, so
each one grabs its own **worker slot** using a Postgres advisory lock
(`config.go``applyTestWorkerSlot` / `claimTestWorkerSlot`). The slot gives it a private copy of
everything shared:
- its own **metadata database**, named `…_w{slot}`;
- its own **Valkey/Redis database** (the slot number);
- its own **cache prefix** `"w{slot}:"`, which also tags the **backup-node registry**
(`backups/backups/backuping/nodes/registry.go`) — every Redis key and pub/sub channel.
So two packages running side by side never touch each other's databases, cache entries, backup nodes or
pub/sub channels.
## Alternatives considered
- **docker-compose, all versions up at once.** Rejected: ~50 containers exhaust RAM on 16 GB CI and the
packages share one set of databases with no isolation.
- **A fresh container per test.** Rejected: 40+ boots per package × 2060s cold start → 15-minute
timeouts.
- **Database files on disk instead of RAM.** Rejected: the slow part is fsync on cold start and on the
write-heavy restore; RAM removes it for servers we throw away anyway.
## Consequences
### Positive
- The two packages that used to time out at 900s now finish in ~30s; the whole suite went from timing
out to ~1.54 minutes.
- Peak memory is bounded and predictable — one matrix container per package — and parallel packages are
fully isolated.
### Negative
- Because a server is reused across a version's subtests, authors must `DROP ... IF EXISTS` for
fixed-name objects and must not run those subtests with `t.Parallel`.
- Data in RAM is volatile — fine for throwaway test servers, but there is no crash safety.
- If a CI runner is short on memory, lower `TEST_PARALLEL_WORKERS` (e.g. to 6).
### Neutral / follow-ups
- On a hard `-timeout` or `os.Exit`, `t.Cleanup` is skipped; the Makefile/CI label-sweep cleans up any
leftover containers.
## References
- `backend/internal/util/testing/containers/{postgres,mysql,mariadb,mongodb}.go` (the `StartXxx` helpers)
- `backend/internal/features/tests/logical/postgresql/backup_restore_test.go` — the per-version
orchestrator pattern
- `backend/internal/features/tests/physical/postgresql/{pg17,pg18}` — the physical backup→restore
E2E split one package per PostgreSQL major, so the two versions run as isolated, parallel test
binaries (each with its own control plane and throwaway source + restore-target containers)
- `backend/internal/config/config.go` (`applyTestWorkerSlot` / `claimTestWorkerSlot`)
- `backend/internal/features/backups/backups/backuping/nodes/registry.go`
- `backend/Makefile`
+23
View File
@@ -0,0 +1,23 @@
# Local dev state — regenerated at runtime, never belongs in a build context
databasus-verification.log
databasus-verification.log.old
databasus-verification.daemon.log
databasus-verification.lock
databasus-verification.json
# Local build artifacts (host binaries)
databasus-verification-agent
# E2E working directories
e2e/artifacts/
# VCS and editor
.git/
.gitignore
.idea/
.vscode/
# Docs / env
*.md
.env
.env.example
+3
View File
@@ -0,0 +1,3 @@
DATABASUS_URL=
AGENT_ID=
AGENT_TOKEN=
+18
View File
@@ -0,0 +1,18 @@
# Runtime state — regenerated at runtime, never committed
databasus-verification.json
databasus-verification.log
databasus-verification.log.old
databasus-verification.daemon.log
databasus-verification.lock
.env
.test-tmp/
# Local build artifacts (host binaries)
databasus-verification-agent
# E2E working directories
e2e/artifacts/
# Editor / VCS
.idea/
.vscode/
+41
View File
@@ -0,0 +1,41 @@
version: "2"
run:
timeout: 5m
tests: false
concurrency: 4
linters:
default: standard
enable:
- funcorder
- bodyclose
- errorlint
- gocritic
- unconvert
- misspell
- errname
- noctx
- modernize
settings:
errcheck:
check-type-assertions: true
formatters:
enable:
- gofumpt
- golines
- gci
settings:
golines:
max-len: 120
gofumpt:
module-path: databasus-verification-agent
extra-rules: true
gci:
sections:
- standard
- default
- localmodule
+289
View File
@@ -0,0 +1,289 @@
# Verification agent guidelines (Go CLI)
Coding standards for the Databasus verification agent — a Go CLI worker that runs on a cloud-managed Linux VM, reports its capacity to the backend over HTTP, and (once the restore phase lands) claims and verifies backups. It has no Gin HTTP server and owns no database schema. The long-running goroutine in this phase is the **capacity heartbeat loop**; the claim/report runner arrives with the restore phase. There is no Windows daemon — the agent runs in the foreground under systemd or as a container.
For project-wide engineering philosophy, naming, and lint/format commands, see the root `CLAUDE.md`. For the backend (Gin/GORM/Swagger) ruleset, see `backend/CLAUDE.md`.
---
## Table of Contents
- [Spacing between logical statements](#spacing-between-logical-statements)
- [Comments](#comments)
- [File organization](#file-organization)
- [Background services](#background-services)
- [Testing](#testing)
- [Time handling](#time-handling)
- [Logging](#logging)
- [Modern Go](#modern-go)
---
## Spacing between logical statements
Add blank lines between logical blocks so the flow is visible at a glance:
- before the final `return`
- after variable declarations, before they're used
- between error handling and subsequent logic
- between distinct logical operations
Bad:
```go
func encodeMessages(messages []Message) (string, error) {
if len(messages) > 0 {
messagesBytes, err := json.Marshal(messages)
if err != nil {
return "", err
}
return string(messagesBytes), nil
}
return "", nil
}
```
Good:
```go
func encodeMessages(messages []Message) (string, error) {
if len(messages) > 0 {
messagesBytes, err := json.Marshal(messages)
if err != nil {
return "", err
}
return string(messagesBytes), nil
}
return "", nil
}
```
---
## Comments
- **No obvious comments** — don't restate what the code already shows.
- **Explain *why*, not *what*** — code shows what happens; comments explain business rules, hidden constraints, or non-obvious optimizations.
- **Prefer refactoring over commenting** — better names or smaller functions usually beat a comment.
- **Complex algorithms deserve comments** — formulas, business rules, non-obvious optimizations.
- **No "Summary" / "Conclusion" sections in `.md` files** unless explicitly requested.
Bad (each comment restates the function name):
```go
// Send heartbeat
sendHeartbeat(request)
// CreateValidLogItems creates valid log items for testing
func CreateValidLogItems(count int, uniqueID string) []LogItemRequestDTO {
```
---
## File organization
One responsibility per file. Don't dump a whole package into one file — split
by role so a reader can find a type by its filename. Conventional names within
a feature package:
- `doc.go` — package doc comment, once the package spans more than one file
- `<feature>.go` — the core type and its methods (the orchestrator/executor)
- `dto.go` — request/response and cross-package data + interface seams
- `errors.go` — sentinel errors (`var Err... = errors.New(...)`)
- `enums.go` — typed-constant groups (`type Status string` + its values)
- `constants.go` — package-level constants that aren't an enum
- background loops, reapers, and pools get their own file (`reaper.go`, `pool.go`)
Only create a file when there is real content for it — an empty `enums.go` or
`constants.go` is noise, not structure. Test files mirror the source split:
`restorer.go``restorer_test.go`, `diskexhaustion.go`
`diskexhaustion_test.go`.
---
## Background services
The agent ships at least one long-running goroutine (the capacity `Heartbeater`; also `BackgroundUpgrader`). Calling `Run()` twice on the same instance is always a bug — duplicate goroutines leak resources and corrupt state. **Always panic; never just log a warning.**
```go
type Heartbeater struct {
// ...
hasRun atomic.Bool
}
func (h *Heartbeater) Run(ctx context.Context) {
if h.hasRun.Swap(true) {
panic(fmt.Sprintf("%T.Run() called multiple times", h))
}
ticker := time.NewTicker(heartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
h.beat(ctx, logger)
}
}
}
```
`atomic.Bool.Swap(true)` does the check-and-set atomically — no `sync.Once` needed.
---
## Testing
**Always run tests after writing them and verify they pass.**
### Naming
- `Test_WhatWeDo_WhatWeExpect`
- `Test_WhatWeDo_WhichConditions_WhatWeExpect`
Examples: `Test_DeriveCapacity_WhenConcurrentJobsExceedCPU_ReturnsError`, `Test_ValidateTransport_WhenHttpAndNotTTYWithoutFlag_FailsFast`, `Test_Heartbeat_WhenCalled_SendsFlatEnvelopeWithBearerAndAgentPath`, `Test_BackgroundUpgrader_WhenRunCalledTwice_Panics`.
### Where tests live
- **Unit / package tests**: alongside the code, named `*_test.go` (e.g. `internal/features/heartbeat/heartbeat_test.go`).
- **End-to-end tests**: under `agent/verification/e2e/`. Run via `make e2e` / `make e2e-clean`.
The agent has no HTTP API of its own, so there are no controller tests — test exported functions of the packages, the CLI commands, and the HTTP-client/IO logic directly.
### Refactor tests as you touch them
When editing existing tests, look for repetitive setup that should become a helper, oversized tests that should be split, and similar patterns across files that should be consolidated. Helpers live in a `testing.go` file inside the package.
### Clean up test data
If a test creates files, processes, or external state, clean it up via `t.Cleanup(...)` or `defer`. Skip cleanup only when the test uses an isolated tempdir the OS reclaims, or when it explicitly validates a failure path where cleanup isn't possible.
---
## Time handling
Always use `time.Now().UTC()` instead of `time.Now()` to keep timezones consistent across the application.
---
## Logging
We use `log/slog`. Three rules.
### 1. Scope IDs early via `logger.With(...)`
Attach `agent_id`, `verification_id`, `upgrade_target`, etc. as soon as you know them so every downstream line carries them automatically. For background services, also scope `job_id` (fresh UUID per execution) and `job_name` (stable snake_case const) in `Run()`.
```go
const jobName = "verification_heartbeat"
func (h *Heartbeater) Run(ctx context.Context) {
logger := h.log.With("job_id", uuid.New(), "job_name", jobName)
logger.Info("heartbeat loop started")
// every subsequent log carries job_id + job_name
}
```
### 2. Values in message, IDs and errors as kv pairs
Sizes, counts, and status transitions go into the message via `fmt.Sprintf`. IDs and errors stay as structured kv pairs so they're searchable in log aggregation tools.
```go
// good
logger.Info(fmt.Sprintf("heartbeat ok: last_seen_at=%s", seenAt))
logger.Info("agent registered", "agent_id", agentID)
logger.Error("heartbeat failed", "error", err)
// bad — ID buried in the message string, error formatted instead of attached
logger.Info(fmt.Sprintf("agent registered %s", agentID))
logger.Error(fmt.Sprintf("heartbeat failed: %v", err))
```
### 3. Style and level
- All keys `snake_case` (`agent_id`, `verification_id`) — never camelCase.
- Messages start lowercase, no trailing period.
- **Debug**: routine ops, function entry, query result counts.
- **Info**: significant state changes, completed actions (`"agent started"`, `"heartbeat loop stopped"`).
- **Warn**: degraded but recoverable (`"heartbeat failed, will retry next tick"`, `"upgrade skipped: same version"`).
- **Error**: failures that need attention (`"auto-update failed"`, `"failed to re-exec after upgrade"`).
### What never goes into logs
- **Passwords, tokens, API keys, full `Authorization` headers** — centralize redaction; don't redact ad-hoc at call sites (config logging masks the token via `maskSensitive`).
- **Full request / response bodies** — log only the fields you actually need.
---
## Modern Go
Prefer modern stdlib idioms over manual equivalents.
### `slices` package — avoid manual loops
```go
slices.Contains(items, x)
slices.Index(items, x) // returns index or -1
slices.IndexFunc(items, func(item T) bool { return item.ID == id })
slices.SortFunc(items, func(a, b T) int { return cmp.Compare(a.X, b.X) })
slices.Sort(items)
slices.Max(items) / slices.Min(items)
slices.Reverse(items) // in-place
slices.Compact(items) // remove consecutive duplicates
slices.Clone(s)
slices.Clip(s)
```
### Quick wins
- `any` instead of `interface{}`.
- `for i := range len(items)` instead of `for i := 0; i < len(items); i++`.
- `sync.OnceFunc(fn)` / `sync.OnceValue(fn)` instead of `sync.Once` + wrapper.
- `t.Context()` in tests instead of `context.WithCancel(context.Background())` + `defer cancel()` — auto-cancels at test end.
- `wg.Go(fn)` instead of `wg.Add(1)` + `go func() { defer wg.Done(); fn() }()`.
- `slog.DiscardHandler` for a no-op logger in tests.
### `context` helpers
```go
stop := context.AfterFunc(ctx, cleanup) // run cleanup on cancellation
ctx, cancel := context.WithTimeoutCause(parent, d, ErrTimeout) // timeout with cause
ctx, cancel := context.WithDeadlineCause(parent, deadline, ErrDeadline) // deadline with cause
```
### `omitzero` instead of `omitempty` for non-nullable types
`omitempty` is broken for `time.Duration`, `time.Time`, structs, slices, and maps — it doesn't omit a zero value. Use `omitzero`:
```go
// good
type Config struct {
Timeout time.Duration `json:"timeout,omitzero"`
CreatedAt time.Time `json:"createdAt,omitzero"`
}
// bad
type Config struct {
Timeout time.Duration `json:"timeout,omitempty"` // broken for Duration!
CreatedAt time.Time `json:"createdAt,omitempty"`
}
```
### `new(val)` for pointer literals (Go 1.26+)
`new()` accepts expressions, eliminating the temp-variable pattern:
```go
// good
cfg := Config{Timeout: new(30), Debug: new(true)}
// bad
timeout := 30
debug := true
cfg := Config{Timeout: &timeout, Debug: &debug}
```
+44
View File
@@ -0,0 +1,44 @@
.PHONY: run build test lint e2e e2e-clean
-include .env
export
CLEAN_ARTIFACTS := rm -rf e2e/artifacts
DOWN_QUIET := cd e2e && docker compose down -v --remove-orphans 2>/dev/null
DOWN_CLEAN_QUIET := cd e2e && docker compose down -v --rmi local 2>/dev/null
run:
go run ./cmd run \
--databasus-host $(DATABASUS_URL) \
--agent-id $(AGENT_ID) \
--token $(AGENT_TOKEN) \
--max-cpu 4 \
--max-ram-mb 4096 \
--max-disk-gb 1000 \
--max-concurrent-jobs 2 \
--allow-insecure-http \
--skip-update
build:
CGO_ENABLED=0 go build -ldflags "-X main.Version=$(VERSION)" -o databasus-verification-agent ./cmd
test:
go test -race -count=1 -failfast ./internal/...
lint:
golangci-lint fmt ./cmd/... ./internal/... ./e2e/... && golangci-lint run ./cmd/... ./internal/... ./e2e/...
e2e:
-$(DOWN_QUIET)
-$(CLEAN_ARTIFACTS)
cd e2e && docker compose build --no-cache e2e-mock-server
cd e2e && docker compose build
cd e2e && docker compose run --rm e2e-agent-builder
cd e2e && docker compose run --rm e2e-fixture-gen
cd e2e && docker compose up -d e2e-mock-server
cd e2e && docker compose run --rm e2e-agent-runner
cd e2e && docker compose down -v
e2e-clean:
-$(DOWN_CLEAN_QUIET)
-$(CLEAN_ARTIFACTS)
+209
View File
@@ -0,0 +1,209 @@
package main
import (
"errors"
"flag"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"databasus-verification-agent/internal/config"
"databasus-verification-agent/internal/features/api"
"databasus-verification-agent/internal/features/start"
"databasus-verification-agent/internal/features/upgrade"
"databasus-verification-agent/internal/logger"
)
var Version = "dev"
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
switch os.Args[1] {
case "start":
runStart(os.Args[2:])
case "run":
runAgent(os.Args[2:])
case "_run":
runDaemon(os.Args[2:])
case "stop":
runStop()
case "status":
runStatus()
case "version":
fmt.Println(Version)
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
printUsage()
os.Exit(1)
}
}
func runStart(args []string) {
fs := flag.NewFlagSet("start", flag.ExitOnError)
isSkipUpdate := fs.Bool("skip-update", false, "Skip auto-update check")
cfg := &config.Config{}
cfg.LoadFromJSONAndArgs(fs, args)
if err := cfg.SaveToJSON(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to save config: %v\n", err)
}
log := logger.GetLogger()
isDev := checkIsDevelopment()
runUpdateCheck(cfg.DatabasusHost, *isSkipUpdate, isDev, log)
if err := start.Start(cfg, Version, isDev, log); err != nil {
if errors.Is(err, upgrade.ErrUpgradeRestart) {
reexecAfterUpgrade(log)
}
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func runAgent(args []string) {
fs := flag.NewFlagSet("run", flag.ExitOnError)
isSkipUpdate := fs.Bool("skip-update", false, "Skip auto-update check")
cfg := &config.Config{}
cfg.LoadFromJSONAndArgs(fs, args)
if err := cfg.SaveToJSON(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to save config: %v\n", err)
}
log := logger.GetLogger()
isDev := checkIsDevelopment()
runUpdateCheck(cfg.DatabasusHost, *isSkipUpdate, isDev, log)
if err := start.RunDaemon(cfg, Version, isDev, log); err != nil {
if errors.Is(err, upgrade.ErrUpgradeRestart) {
reexecAfterUpgrade(log)
}
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func runDaemon(args []string) {
fs := flag.NewFlagSet("_run", flag.ExitOnError)
if err := fs.Parse(args); err != nil {
os.Exit(1)
}
log := logger.GetLogger()
cfg := &config.Config{}
cfg.LoadFromJSON()
if err := start.RunDaemon(cfg, Version, checkIsDevelopment(), log); err != nil {
if errors.Is(err, upgrade.ErrUpgradeRestart) {
reexecAfterUpgrade(log)
}
log.Error("Agent exited with error", "error", err)
os.Exit(1)
}
}
func runStop() {
log := logger.GetLogger()
if err := start.Stop(log); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func runStatus() {
log := logger.GetLogger()
if err := start.Status(log); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func printUsage() {
fmt.Fprintln(os.Stderr, "Usage: databasus-verification-agent <command> [flags]")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Commands:")
fmt.Fprintln(os.Stderr, " start Start the verification agent in the background (daemon)")
fmt.Fprintln(os.Stderr, " run Run the verification agent in the foreground (systemd / container)")
fmt.Fprintln(os.Stderr, " stop Stop a running agent")
fmt.Fprintln(os.Stderr, " status Show agent status")
fmt.Fprintln(os.Stderr, " version Print agent version")
}
func runUpdateCheck(host string, isSkipUpdate, isDev bool, log *slog.Logger) {
if isSkipUpdate {
return
}
if host == "" {
return
}
apiClient := api.NewClient(host, "", "", log)
isUpgraded, err := upgrade.CheckAndUpdate(apiClient, Version, isDev, log)
if err != nil {
log.Error("Auto-update failed", "error", err)
os.Exit(1)
}
if isUpgraded {
reexecAfterUpgrade(log)
}
}
func checkIsDevelopment() bool {
dir, err := os.Getwd()
if err != nil {
return false
}
for range 3 {
if data, err := os.ReadFile(filepath.Join(dir, ".env")); err == nil {
return parseEnvMode(data)
}
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return false
}
dir = filepath.Dir(dir)
}
return false
}
func parseEnvMode(data []byte) bool {
for line := range strings.SplitSeq(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 && strings.TrimSpace(parts[0]) == "ENV_MODE" {
return strings.TrimSpace(parts[1]) == "development"
}
}
return false
}
+22
View File
@@ -0,0 +1,22 @@
package main
import (
"log/slog"
"os"
"syscall"
)
func reexecAfterUpgrade(log *slog.Logger) {
selfPath, err := os.Executable()
if err != nil {
log.Error("Failed to resolve executable for re-exec", "error", err)
os.Exit(1)
}
log.Info("Re-executing after upgrade...")
if err := syscall.Exec(selfPath, os.Args, os.Environ()); err != nil {
log.Error("Failed to re-exec after upgrade", "error", err)
os.Exit(1)
}
}
+4
View File
@@ -0,0 +1,4 @@
# Host port the e2e mock-server publishes. The agent-runner reaches it via
# 127.0.0.1:<port> (network_mode: host). Defaults to 4050 in docker-compose.yml;
# override here only if 4050 is already taken on the host.
E2E_MOCK_SERVER_PORT=4050
@@ -0,0 +1,14 @@
# Builds verification agent binaries with different versions so the upgrade
# behaviour (v1 -> v2) can be tested. Built from ./cmd (not ./cmd/main.go)
# because the package has platform-split files.
FROM golang:1.26.3-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=v1.0.0" -o /out/agent-v1 ./cmd
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=v2.0.0" -o /out/agent-v2 ./cmd
FROM alpine:3.21
COPY --from=build /out/ /out/
CMD ["cp", "-v", "/out/agent-v1", "/out/agent-v2", "/artifacts/"]
@@ -0,0 +1,13 @@
# Runs the verification agent against the mock server. The agent spawns
# ephemeral Postgres containers on the host daemon (docker socket mounted by
# compose); docker-cli here is for the e2e scripts themselves (leak_check
# inspects containers/networks/volumes by label after each terminal outcome).
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl docker.io jq && \
rm -rf /var/lib/apt/lists/*
WORKDIR /tmp
ENTRYPOINT []
@@ -0,0 +1,16 @@
# Generates one pg_dump fixture per supported major (good-pg12.dump ..
# good-pg18.dump) plus a single broken.dump sentinel. The entrypoint shells
# out to docker against the host daemon (mounted /var/run/docker.sock) and
# spawns a sibling postgres:N container per iteration so each dump is produced
# by the matching server. No in-container postgres binaries needed.
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates docker.io && \
rm -rf /var/lib/apt/lists/*
COPY fixture-gen/entrypoint.sh /usr/local/bin/fixture-gen
RUN chmod +x /usr/local/bin/fixture-gen
ENTRYPOINT []
CMD ["/usr/local/bin/fixture-gen"]
@@ -0,0 +1,10 @@
# Mock Databasus API server for version checks, verification-agent binary
# downloads and heartbeat capture. Serves static responses and files.
FROM golang:1.26.3-alpine AS build
WORKDIR /app
COPY mock-server/main.go .
RUN CGO_ENABLED=0 go build -o mock-server main.go
FROM alpine:3.21
COPY --from=build /app/mock-server /usr/local/bin/mock-server
ENTRYPOINT ["mock-server"]
+65
View File
@@ -0,0 +1,65 @@
services:
e2e-agent-builder:
build:
context: ..
dockerfile: e2e/Dockerfile.agent-builder
volumes:
- ./artifacts:/artifacts
container_name: e2e-verification-agent-builder
e2e-fixture-gen:
build:
context: .
dockerfile: Dockerfile.fixture-gen
volumes:
- ./artifacts:/artifacts
# The entrypoint spawns one sibling postgres:N container per supported
# major (12..18) and pipes pg_dump out via docker exec, so it talks to
# the host daemon the same way e2e-agent-runner does.
- /var/run/docker.sock:/var/run/docker.sock
container_name: e2e-verification-fixture-gen
e2e-mock-server:
build:
context: .
dockerfile: Dockerfile.mock-server
volumes:
- ./artifacts:/artifacts:ro
container_name: e2e-verification-mock-server
# Published to host so e2e-agent-runner (network_mode: host) can reach it
# via 127.0.0.1:<port> — same path the spawned Postgres ports use. Host
# port defaults to 4050; override via E2E_MOCK_SERVER_PORT in
# agent/verification/e2e/.env if 4050 is already taken.
ports:
- "${E2E_MOCK_SERVER_PORT:-4050}:4050"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:4050/health"]
interval: 2s
timeout: 5s
retries: 10
e2e-agent-runner:
build:
context: .
dockerfile: Dockerfile.agent-runner
volumes:
- ./artifacts:/opt/agent/artifacts:ro
- ./scripts:/opt/agent/scripts:ro
# The agent's Docker SDK client spawns the ephemeral Postgres containers
# on the host daemon; pg_restore runs inside those containers (no host
# client, no assets bundle).
- /var/run/docker.sock:/var/run/docker.sock
# Mirrors production: the agent runs directly on the VM, so 127.0.0.1 IS
# the host. Spawned Postgres containers publish 5432 to a random host port
# bound to the host's 127.0.0.1 (dockerengine.go binds {HostIP:"127.0.0.1",
# HostPort:"0"}); the agent-process verifier reaches them on that loopback.
# A bridge-network runner cannot see those ports — its 127.0.0.1 is its
# own. Linux-only; document accordingly.
network_mode: host
environment:
- E2E_MOCK_SERVER_PORT=${E2E_MOCK_SERVER_PORT:-4050}
depends_on:
e2e-mock-server:
condition: service_healthy
container_name: e2e-verification-agent-runner
command: ["bash", "/opt/agent/scripts/run-all.sh"]
@@ -0,0 +1,111 @@
#!/bin/bash
# Produces /artifacts/good-pgN.dump for each supported Postgres major, one
# /artifacts/good-timescale-pg17.dump (a hypertable dump), and a single
# /artifacts/broken.dump sentinel. For each fixture: spawn a sibling container
# against the host daemon, wait for pg_isready, seed the schema, pg_dump -Fc to
# the artifacts dir, and remove the container. Producer and consumer (pg_restore
# in the agent's spawned target) share the image so the archive header and the
# extension catalog version match.
set -euo pipefail
VERSIONS=(12 13 14 15 16 17 18)
ARTIFACTS=/artifacts
BROKEN="$ARTIFACTS/broken.dump"
mkdir -p "$ARTIFACTS"
CID=""
cleanup() {
if [ -n "${CID:-}" ]; then
docker rm -f "$CID" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
for V in "${VERSIONS[@]}"; do
CID="$(docker run -d -e POSTGRES_PASSWORD=test "postgres:$V")"
READY=0
for _ in $(seq 1 30); do
if docker exec "$CID" pg_isready -U postgres -h 127.0.0.1 >/dev/null 2>&1; then
READY=1
break
fi
sleep 1
done
if [ "$READY" -ne 1 ]; then
echo "FAIL: postgres:$V never became ready within 30s"
docker logs "$CID" 2>&1 | tail -30
exit 1
fi
docker exec -i "$CID" psql -U postgres -d postgres -v ON_ERROR_STOP=1 <<'SQL'
CREATE TABLE public.t_a (id int PRIMARY KEY, name text);
CREATE TABLE public.t_b (
id int PRIMARY KEY,
a_id int NOT NULL REFERENCES public.t_a(id),
value text
);
INSERT INTO public.t_a (id, name) VALUES (1, 'alpha'), (2, 'beta'), (3, 'gamma');
INSERT INTO public.t_b (id, a_id, value) VALUES
(1, 1, 'x'), (2, 1, 'y'), (3, 2, 'z'), (4, 3, 'w'), (5, 3, 'v');
SQL
OUT="$ARTIFACTS/good-pg${V}.dump"
docker exec "$CID" pg_dump -Fc -U postgres postgres > "$OUT"
docker rm -f "$CID" >/dev/null
CID=""
echo "fixture: good-pg${V}.dump=$(stat -c%s "$OUT")B"
done
# TimescaleDB fixture: dumped from the exact image the agent restores into
# (timescale/timescaledb:2.17.0-pg17), so the archive and the extension catalog
# version match. It carries a hypertable spanning many chunks; restoring it
# needs the agent's timescaledb_pre_restore / timescaledb_post_restore wrapping
# and single-threaded -j, or the _timescaledb_catalog restore fails.
TS_IMAGE="timescale/timescaledb:2.17.0-pg17"
TS_OUT="$ARTIFACTS/good-timescale-pg17.dump"
CID="$(docker run -d -e POSTGRES_PASSWORD=test "$TS_IMAGE")"
READY=0
for _ in $(seq 1 30); do
if docker exec "$CID" pg_isready -U postgres -h 127.0.0.1 >/dev/null 2>&1; then
READY=1
break
fi
sleep 1
done
if [ "$READY" -ne 1 ]; then
echo "FAIL: timescaledb never became ready within 30s"
docker logs "$CID" 2>&1 | tail -30
exit 1
fi
docker exec -i "$CID" psql -U postgres -d postgres -v ON_ERROR_STOP=1 <<'SQL'
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE TABLE public.sensor_data (
time TIMESTAMPTZ NOT NULL,
sensor_id int NOT NULL,
temperature double precision NOT NULL
);
SELECT create_hypertable('public.sensor_data', 'time');
INSERT INTO public.sensor_data (time, sensor_id, temperature)
SELECT ts, (random() * 10)::int, random() * 100
FROM generate_series('2024-01-01'::timestamptz, '2024-03-01'::timestamptz, interval '1 hour') AS ts;
SQL
docker exec "$CID" pg_dump -Fc -U postgres postgres > "$TS_OUT"
docker rm -f "$CID" >/dev/null
CID=""
echo "fixture: good-timescale-pg17.dump=$(stat -c%s "$TS_OUT")B"
printf 'not-a-valid-pg-dump-custom-format-archive\n' > "$BROKEN"
chmod 644 "$ARTIFACTS"/good-*.dump "$BROKEN"
echo "fixture: broken.dump=$(stat -c%s "$BROKEN")B"
+368
View File
@@ -0,0 +1,368 @@
// Mock Databasus API server for the verification agent e2e: serves version
// checks, the verification-agent binary download, the capacity heartbeat, and
// the job protocol (claim / backup-stream / report) with fault-injection
// controls. Stdlib only — built standalone via `go build main.go`.
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"sync"
"time"
)
type server struct {
mu sync.RWMutex
version string
binaryPath string
heartbeats int
lastBody map[string]any
// job protocol state
claimJob map[string]any
backupFixturePath string
reports []map[string]any
abortIDs []string
streamFailRemain int
tearStreamOnce bool
reportGone bool
}
func main() {
s := &server{version: "v1.0.0", binaryPath: "/artifacts/agent-v1"}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/system/version", s.handleVersion)
mux.HandleFunc("GET /api/v1/system/verification-agent", s.handleDownload)
mux.HandleFunc("POST /api/v1/agent/verification/{agentId}/heartbeat", s.handleHeartbeat)
mux.HandleFunc("POST /api/v1/agent/verifications/{agentId}/claim", s.handleClaim)
mux.HandleFunc("GET /api/v1/agent/verifications/{agentId}/{id}/backup-stream", s.handleBackupStream)
mux.HandleFunc("POST /api/v1/agent/verifications/{agentId}/{id}/report", s.handleReport)
mux.HandleFunc("POST /mock/set-version", s.handleSetVersion)
mux.HandleFunc("POST /mock/set-binary-path", s.handleSetBinaryPath)
mux.HandleFunc("GET /mock/heartbeats", s.handleHeartbeatStatus)
mux.HandleFunc("POST /mock/set-claim", s.handleSetClaim)
mux.HandleFunc("POST /mock/set-backup-fixture", s.handleSetBackupFixture)
mux.HandleFunc("POST /mock/set-abort", s.handleSetAbort)
mux.HandleFunc("POST /mock/set-stream-fail", s.handleSetStreamFail)
mux.HandleFunc("POST /mock/set-report-gone", s.handleSetReportGone)
mux.HandleFunc("GET /mock/reports", s.handleGetReports)
mux.HandleFunc("POST /mock/reset", s.handleReset)
mux.HandleFunc("GET /health", s.handleHealth)
addr := ":4050"
log.Printf("mock server starting on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("server failed: %v", err)
}
}
func (s *server) handleVersion(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
version := s.version
s.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"version": version})
}
func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
path := s.binaryPath
version := s.version
s.mu.RUnlock()
log.Printf("GET verification-agent (arch=%s) -> %s", r.URL.Query().Get("arch"), path)
w.Header().Set("X-Databasus-Version", version)
http.ServeFile(w, r, path)
}
func (s *server) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
s.heartbeats++
s.lastBody = body
count := s.heartbeats
abortIDs := append([]string{}, s.abortIDs...)
s.mu.Unlock()
log.Printf("POST heartbeat (agent=%s) -> count=%d", r.PathValue("agentId"), count)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"lastSeenAt": time.Now().UTC().Format(time.RFC3339),
"abortVerificationIds": abortIDs,
})
}
func (s *server) handleClaim(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
job := s.claimJob
s.claimJob = nil // one-shot: a single verification per test run
s.mu.Unlock()
if job == nil {
w.WriteHeader(http.StatusNoContent)
return
}
log.Printf("POST claim (agent=%s) -> assigning %v", r.PathValue("agentId"), job["verificationId"])
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(job)
}
func (s *server) handleBackupStream(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
failRemain := s.streamFailRemain
if failRemain > 0 {
s.streamFailRemain--
}
tear := s.tearStreamOnce
s.tearStreamOnce = false
fixture := s.backupFixturePath
s.mu.Unlock()
if failRemain > 0 {
log.Printf("GET backup-stream -> injected 503 (remaining=%d)", failRemain-1)
w.WriteHeader(http.StatusServiceUnavailable)
return
}
if tear {
log.Printf("GET backup-stream -> tearing connection mid-body")
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write([]byte("PARTIAL"))
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
if hj, ok := w.(http.Hijacker); ok {
conn, _, err := hj.Hijack()
if err == nil {
_ = conn.Close()
}
}
return
}
log.Printf("GET backup-stream (id=%s) -> %s", r.PathValue("id"), fixture)
w.Header().Set("Content-Type", "application/octet-stream")
http.ServeFile(w, r, fixture)
}
func (s *server) handleReport(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
body["verificationId"] = r.PathValue("id")
s.mu.Lock()
gone := s.reportGone
if !gone {
s.reports = append(s.reports, body)
}
s.mu.Unlock()
if gone {
log.Printf("POST report (id=%s) -> 410 gone", r.PathValue("id"))
w.WriteHeader(http.StatusGone)
_, _ = w.Write([]byte(`{"reason":"gone"}`))
return
}
log.Printf("POST report (id=%s) -> status=%v exit=%v", r.PathValue("id"), body["status"], body["pgRestoreExitCode"])
w.WriteHeader(http.StatusNoContent)
}
func (s *server) handleSetVersion(w http.ResponseWriter, r *http.Request) {
var body struct {
Version string `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
s.version = body.Version
s.mu.Unlock()
log.Printf("POST /mock/set-version -> %s", body.Version)
w.WriteHeader(http.StatusOK)
}
func (s *server) handleSetBinaryPath(w http.ResponseWriter, r *http.Request) {
var body struct {
BinaryPath string `json:"binaryPath"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
s.binaryPath = body.BinaryPath
s.mu.Unlock()
log.Printf("POST /mock/set-binary-path -> %s", body.BinaryPath)
w.WriteHeader(http.StatusOK)
}
func (s *server) handleSetClaim(w http.ResponseWriter, r *http.Request) {
var job map[string]any
if err := json.NewDecoder(r.Body).Decode(&job); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
s.claimJob = job
s.mu.Unlock()
log.Printf("POST /mock/set-claim -> %v", job["verificationId"])
w.WriteHeader(http.StatusOK)
}
func (s *server) handleSetBackupFixture(w http.ResponseWriter, r *http.Request) {
var body struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if _, err := os.Stat(body.Path); err != nil {
http.Error(w, "fixture not found: "+err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
s.backupFixturePath = body.Path
s.mu.Unlock()
log.Printf("POST /mock/set-backup-fixture -> %s", body.Path)
w.WriteHeader(http.StatusOK)
}
func (s *server) handleSetAbort(w http.ResponseWriter, r *http.Request) {
var body struct {
IDs []string `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
s.abortIDs = body.IDs
s.mu.Unlock()
log.Printf("POST /mock/set-abort -> %v", body.IDs)
w.WriteHeader(http.StatusOK)
}
func (s *server) handleSetStreamFail(w http.ResponseWriter, r *http.Request) {
var body struct {
Count int `json:"count"`
TearStreamOnce bool `json:"tearStreamOnce"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
s.streamFailRemain = body.Count
s.tearStreamOnce = body.TearStreamOnce
s.mu.Unlock()
log.Printf("POST /mock/set-stream-fail -> count=%d tear=%v", body.Count, body.TearStreamOnce)
w.WriteHeader(http.StatusOK)
}
func (s *server) handleSetReportGone(w http.ResponseWriter, r *http.Request) {
var body struct {
Gone bool `json:"gone"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
s.reportGone = body.Gone
s.mu.Unlock()
log.Printf("POST /mock/set-report-gone -> %v", body.Gone)
w.WriteHeader(http.StatusOK)
}
func (s *server) handleGetReports(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"count": len(s.reports),
"reports": s.reports,
})
}
func (s *server) handleHeartbeatStatus(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"count": s.heartbeats,
"last": s.lastBody,
})
}
// handleReset wipes per-test state (reports, claim, abort/stream/report-gone
// flags, fixture path). Version + binary path are left alone — pin them via
// /mock/set-version + /mock/set-binary-path. Each restore-style e2e test
// should call this at the top to avoid seeing prior-test artifacts.
func (s *server) handleReset(w http.ResponseWriter, _ *http.Request) {
s.mu.Lock()
s.reports = nil
s.claimJob = nil
s.backupFixturePath = ""
s.abortIDs = nil
s.streamFailRemain = 0
s.tearStreamOnce = false
s.reportGone = false
s.heartbeats = 0
s.lastBody = nil
s.mu.Unlock()
log.Printf("POST /mock/reset -> state wiped")
w.WriteHeader(http.StatusOK)
}
func (s *server) handleHealth(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
+137
View File
@@ -0,0 +1,137 @@
#!/bin/bash
# Shared helpers for verification-agent e2e scripts (sourced by each
# scripts/test-*.sh). The runner uses network_mode: host so 127.0.0.1:4050
# reaches the published mock-server port AND the random host ports the
# spawned Postgres containers bind 5432 to — same setup as production
# (agent on the VM, sibling containers).
MOCK="http://127.0.0.1:${E2E_MOCK_SERVER_PORT:-4050}"
ARTIFACTS="/opt/agent/artifacts"
hb_count() {
curl -sf "$MOCK/mock/heartbeats" | sed -n 's/.*"count":\([0-9]*\).*/\1/p'
}
reports_json() {
curl -sf "$MOCK/mock/reports"
}
# reset_mock_version pins the mock to v1.0.0 / agent-v1 so the agent's
# background upgrader finds no newer version and the process stays up for the
# whole test.
reset_mock_version() {
curl -sf -X POST "$MOCK/mock/set-version" \
-H 'Content-Type: application/json' -d '{"version":"v1.0.0"}'
curl -sf -X POST "$MOCK/mock/set-binary-path" \
-H 'Content-Type: application/json' -d '{"binaryPath":"/artifacts/agent-v1"}'
}
# reset_mock_state wipes per-test state (reports, claim, fixture, abort/stream
# flags, heartbeat counter). The mock is shared across all e2e scripts in one
# run; without this, a later script sees an earlier script's report and bails.
reset_mock_state() {
curl -sf -X POST "$MOCK/mock/reset" >/dev/null
}
# leak_check is non-zero if any container/network labeled with the given agent
# ID survives. The anonymous PGDATA volume carries no label and is removed
# alongside the container by RemoveOptions{RemoveVolumes:true}, so a leaked
# container is the canonical signal of a leaked PGDATA.
leak_check() {
local agent_id="$1"
local label="databasus.verification.agent_id=${agent_id}"
local rc=0
local containers
containers="$(docker ps -a --filter "label=${label}" --format '{{.ID}} {{.Names}}')"
if [ -n "$containers" ]; then
echo "FAIL: leaked containers labeled ${label}:"
echo "$containers"
rc=1
fi
local networks
networks="$(docker network ls --filter "label=${label}" --format '{{.ID}} {{.Name}}')"
if [ -n "$networks" ]; then
echo "FAIL: leaked networks labeled ${label}:"
echo "$networks"
rc=1
fi
return $rc
}
# start_agent <agent_id> [extra_flag...] — runs `databasus-verification-agent run`
# with the standard launch block in the current dir, redirects stdout+stderr to
# ./agent.out, and sets AGENT_PID. Extra flags forwarded to the binary.
start_agent() {
local agent_id="$1"
shift
./databasus-verification-agent run \
--databasus-host "$MOCK" \
--agent-id "$agent_id" \
--token test-token \
--max-cpu 4 \
--max-ram-mb 2048 \
--max-disk-gb 20 \
--max-concurrent-jobs 2 \
--allow-insecure-http \
--skip-update \
"$@" > agent.out 2>&1 &
AGENT_PID=$!
}
stop_agent() {
kill "$AGENT_PID" 2>/dev/null || true
wait "$AGENT_PID" 2>/dev/null || true
}
dump_diagnostics() {
echo "---- agent.out (last 30) ----"
tail -30 agent.out 2>/dev/null || echo "(no agent.out)"
echo "---- databasus-verification.log (last 60) ----"
tail -60 databasus-verification.log 2>/dev/null || echo "(no log file)"
}
# wait_for_report <pattern> <deadline_seconds> [fatal_pattern] — polls
# /mock/reports every 2s until grep -q "$pattern" matches. If fatal_pattern is
# set and matches first, exits 1 with diagnostics. On timeout, exits 1 with
# diagnostics. On success, REPORTS holds the body for follow-up asserts.
wait_for_report() {
local want="$1"
local seconds="$2"
local fatal="${3:-}"
local deadline=$((SECONDS + seconds))
REPORTS=""
while [ $SECONDS -lt $deadline ]; do
REPORTS="$(reports_json)"
if echo "$REPORTS" | grep -q "$want"; then
return 0
fi
if [ -n "$fatal" ] && echo "$REPORTS" | grep -q "$fatal"; then
echo "FAIL: saw fatal pattern $fatal before expected $want"
echo "reports: $REPORTS"
dump_diagnostics
exit 1
fi
sleep 2
done
echo "FAIL: no report matching $want within ${seconds}s"
echo "reports: $REPORTS"
dump_diagnostics
exit 1
}
# assert_report <verification_id> <jq_expr> — selects the report whose
# verificationId matches and asserts jq_expr is truthy. On failure prints the
# matching report pretty-printed.
assert_report() {
local vid="$1"
local expr="$2"
if ! reports_json | jq -e --arg vid "$vid" \
"(.reports[] | select(.verificationId == \$vid)) | $expr" >/dev/null; then
echo "FAIL: report assertion failed for $vid: $expr"
reports_json | jq --arg vid "$vid" '.reports[] | select(.verificationId == $vid)'
exit 1
fi
}
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
set -uo pipefail
SCRIPT_DIR="$(dirname "$0")"
TESTS=(
"test-heartbeat-and-upgrade.sh"
"test-start-stop-status.sh"
"test-startup-purge.sh"
"test-restore-broken.sh"
"test-restore-disk-budget.sh"
"test-restore-timescale.sh"
)
PG_VERSIONS=(12 13 14 15 16 17 18)
echo "========================================"
echo " Verification agent e2e"
echo "========================================"
PASSED=0
FAILED=0
for test in "${TESTS[@]}"; do
echo ""
echo "---- $test ----"
if bash "$SCRIPT_DIR/$test"; then
PASSED=$((PASSED + 1))
else
FAILED=$((FAILED + 1))
fi
done
for V in "${PG_VERSIONS[@]}"; do
echo ""
echo "---- test-restore-success.sh $V ----"
if bash "$SCRIPT_DIR/test-restore-success.sh" "$V"; then
PASSED=$((PASSED + 1))
else
FAILED=$((FAILED + 1))
fi
done
echo ""
echo "========================================"
echo " Results: $PASSED passed, $FAILED failed"
echo "========================================"
if [ "$FAILED" -ne 0 ]; then
exit 1
fi
@@ -0,0 +1,122 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/lib.sh"
WORK="/tmp/agent-work"
AGENT_ID="11111111-1111-1111-1111-111111111111"
rm -rf "$WORK"
mkdir -p "$WORK"
cd "$WORK"
reset_mock_version
cp "$ARTIFACTS/agent-v1" ./databasus-verification-agent
chmod +x ./databasus-verification-agent
INITIAL="$(./databasus-verification-agent version)"
if [ "$INITIAL" != "v1.0.0" ]; then
echo "FAIL: expected initial version v1.0.0, got $INITIAL"
exit 1
fi
echo "Initial version: $INITIAL"
./databasus-verification-agent run \
--databasus-host "$MOCK" \
--agent-id "$AGENT_ID" \
--token test-token \
--max-cpu 4 \
--max-ram-mb 2048 \
--max-disk-gb 20 \
--max-concurrent-jobs 2 \
--allow-insecure-http > agent.out 2>&1 &
AGENT_PID=$!
# 1) Capacity heartbeat is received (Run sends one immediately).
COUNT=0
for _ in $(seq 1 20); do
COUNT="$(hb_count || echo 0)"
[ "${COUNT:-0}" -ge 1 ] && break
sleep 1
done
if [ "${COUNT:-0}" -lt 1 ]; then
echo "FAIL: no heartbeat received"
cat agent.out
exit 1
fi
echo "Heartbeat received (count=$COUNT)"
# 2) Heartbeat wire shape: flat maxRamGb (2048 MB -> 2 GB), empty job list.
BODY="$(curl -sf "$MOCK/mock/heartbeats")"
if ! echo "$BODY" | grep -q '"maxRamGb":2'; then
echo "FAIL: heartbeat missing maxRamGb:2"
echo "$BODY"
exit 1
fi
if ! echo "$BODY" | grep -q '"currentVerificationIds":\[\]'; then
echo "FAIL: heartbeat currentVerificationIds not []"
echo "$BODY"
exit 1
fi
echo "Heartbeat wire shape OK"
# 3) Bump version -> background self-update downloads v2 and re-execs.
curl -sf -X POST "$MOCK/mock/set-binary-path" \
-H 'Content-Type: application/json' -d '{"binaryPath":"/artifacts/agent-v2"}'
curl -sf -X POST "$MOCK/mock/set-version" \
-H 'Content-Type: application/json' -d '{"version":"v2.0.0"}'
echo "Mock bumped to v2.0.0, waiting for background upgrade..."
DEADLINE=$((SECONDS + 90))
while [ $SECONDS -lt $DEADLINE ]; do
[ "$(./databasus-verification-agent version)" = "v2.0.0" ] && break
sleep 3
done
FINAL="$(./databasus-verification-agent version)"
if [ "$FINAL" != "v2.0.0" ]; then
echo "FAIL: expected v2.0.0 after background upgrade, got $FINAL"
cat agent.out
exit 1
fi
echo "Binary upgraded to $FINAL"
if ! grep -q "Agent binary updated" agent.out; then
echo "FAIL: upgrade log line missing"
cat agent.out
exit 1
fi
if ! grep -q "Re-executing after upgrade" agent.out; then
echo "FAIL: re-exec log line missing"
cat agent.out
exit 1
fi
# 4) Same PID is alive after re-exec (syscall.Exec preserves the PID).
if ! kill -0 "$AGENT_PID" 2>/dev/null; then
echo "FAIL: agent process died during self-update"
cat agent.out
exit 1
fi
# 5) Heartbeats resume after re-exec.
BEFORE="$(hb_count || echo 0)"
RESUMED=0
for _ in $(seq 1 45); do
AFTER="$(hb_count || echo 0)"
if [ "${AFTER:-0}" -gt "${BEFORE:-0}" ]; then
RESUMED=1
break
fi
sleep 1
done
if [ "$RESUMED" -ne 1 ]; then
echo "FAIL: heartbeats did not resume after re-exec ($BEFORE -> ${AFTER:-0})"
cat agent.out
exit 1
fi
echo "Heartbeats resumed after re-exec ($BEFORE -> $AFTER)"
kill "$AGENT_PID" 2>/dev/null || true
echo "Verification agent e2e passed"
@@ -0,0 +1,51 @@
#!/bin/bash
# Restore e2e (broken dump): mock serves a file that is NOT a valid -Fc
# archive; pg_restore -Fc inside the spawned Postgres container must reject
# it and exit non-zero, and the agent must POST FAILED with a real
# pgRestoreExitCode. Independent of the verifier-conn fix — the failure
# happens at restore, never reaches the verifier.
set -euo pipefail
source "$(dirname "$0")/lib.sh"
WORK="/tmp/agent-work-restore-broken"
AGENT_ID="33333333-3333-3333-3333-333333333333"
VERIFICATION_ID="33333333-aaaa-aaaa-aaaa-333333333333"
BACKUP_ID="33333333-bbbb-bbbb-bbbb-333333333333"
rm -rf "$WORK"
mkdir -p "$WORK"
cd "$WORK"
reset_mock_state
reset_mock_version
cp "$ARTIFACTS/agent-v1" ./databasus-verification-agent
chmod +x ./databasus-verification-agent
start_agent "$AGENT_ID"
curl -sf -X POST "$MOCK/mock/set-backup-fixture" \
-H 'Content-Type: application/json' \
-d '{"path":"/artifacts/broken.dump"}'
curl -sf -X POST "$MOCK/mock/set-claim" \
-H 'Content-Type: application/json' \
-d "{\"verificationId\":\"$VERIFICATION_ID\",\"backupId\":\"$BACKUP_ID\",\"backupSizeMb\":1,\"maxContainerDiskMb\":2048,\"database\":{\"type\":\"POSTGRES_LOGICAL\",\"postgresqlLogical\":{\"version\":\"16\"}}}"
# Spawn + image pull (first run) + restore + report. Generous budget on cold cache.
wait_for_report '"status":"FAILED"' 240
assert_report "$VERIFICATION_ID" '.pgRestoreExitCode >= 1'
echo "Broken-dump report OK: FAILED with non-zero pgRestoreExitCode"
stop_agent
if ! leak_check "$AGENT_ID"; then
echo "---- agent.out ----"
cat agent.out
exit 1
fi
echo "Verification agent restore-broken e2e passed"
@@ -0,0 +1,48 @@
#!/bin/bash
# Disk-budget enforcement (real Docker): tiny maxContainerDiskMb (1 MB) against
# the standard postgres:16 init footprint (~50 MB) makes the disk watcher trip
# on its first poll. The agent must POST FAILED with failureKind:
# DISK_LIMIT_EXCEEDED and the container/anonymous PGDATA volume must be torn
# down. The watcher polls every 3 s so the verdict lands within ~10 s of spawn.
set -euo pipefail
source "$(dirname "$0")/lib.sh"
WORK="/tmp/agent-work-disk-budget"
AGENT_ID="55555555-5555-5555-5555-555555555555"
VERIFICATION_ID="55555555-aaaa-aaaa-aaaa-555555555555"
BACKUP_ID="55555555-bbbb-bbbb-bbbb-555555555555"
rm -rf "$WORK"
mkdir -p "$WORK"
cd "$WORK"
reset_mock_state
reset_mock_version
cp "$ARTIFACTS/agent-v1" ./databasus-verification-agent
chmod +x ./databasus-verification-agent
start_agent "$AGENT_ID"
curl -sf -X POST "$MOCK/mock/set-backup-fixture" \
-H 'Content-Type: application/json' \
-d '{"path":"/artifacts/good-pg16.dump"}'
curl -sf -X POST "$MOCK/mock/set-claim" \
-H 'Content-Type: application/json' \
-d "{\"verificationId\":\"$VERIFICATION_ID\",\"backupId\":\"$BACKUP_ID\",\"backupSizeMb\":1,\"maxContainerDiskMb\":1,\"database\":{\"type\":\"POSTGRES_LOGICAL\",\"postgresqlLogical\":{\"version\":\"16\"}}}"
wait_for_report '"failureKind":"DISK_LIMIT_EXCEEDED"' 180 '"status":"COMPLETED"'
assert_report "$VERIFICATION_ID" '.status == "FAILED"'
echo "Disk-budget verdict OK: FAILED with failureKind:DISK_LIMIT_EXCEEDED"
stop_agent
if ! leak_check "$AGENT_ID"; then
exit 1
fi
echo "Verification agent restore-disk-budget e2e passed"
@@ -0,0 +1,58 @@
#!/bin/bash
# Restore e2e (correct dump) for one Postgres major: mock serves the matching
# -Fc archive of a tiny seeded schema; the agent must run pg_restore inside
# the spawned postgres:N container, then the verifier (out-of-container pgx
# via the container's published host port) must collect tier stats, and the
# agent must POST COMPLETED with non-null dbSizeBytesAfterRestore /
# tableCount / pgRestoreExitCode==0 / non-empty tableStats. The PG major is
# the first positional arg (default 16); run-all.sh loops it over 12..18.
set -euo pipefail
source "$(dirname "$0")/lib.sh"
PG_VERSION="${1:-16}"
WORK="/tmp/agent-work-restore-success-pg${PG_VERSION}"
AGENT_ID="44444444-4444-4444-4444-444444444444"
VERIFICATION_ID="44444444-aaaa-aaaa-aaaa-4444444444${PG_VERSION}"
BACKUP_ID="44444444-bbbb-bbbb-bbbb-4444444444${PG_VERSION}"
rm -rf "$WORK"
mkdir -p "$WORK"
cd "$WORK"
reset_mock_state
reset_mock_version
cp "$ARTIFACTS/agent-v1" ./databasus-verification-agent
chmod +x ./databasus-verification-agent
start_agent "$AGENT_ID"
curl -sf -X POST "$MOCK/mock/set-backup-fixture" \
-H 'Content-Type: application/json' \
-d "{\"path\":\"/artifacts/good-pg${PG_VERSION}.dump\"}"
curl -sf -X POST "$MOCK/mock/set-claim" \
-H 'Content-Type: application/json' \
-d "{\"verificationId\":\"$VERIFICATION_ID\",\"backupId\":\"$BACKUP_ID\",\"backupSizeMb\":1,\"maxContainerDiskMb\":2048,\"database\":{\"type\":\"POSTGRES_LOGICAL\",\"postgresqlLogical\":{\"version\":\"${PG_VERSION}\"}}}"
wait_for_report '"status":"COMPLETED"' 240 '"status":"FAILED"'
assert_report "$VERIFICATION_ID" '.pgRestoreExitCode == 0'
assert_report "$VERIFICATION_ID" '.dbSizeBytesAfterRestore > 0'
assert_report "$VERIFICATION_ID" '.tableCount == 2'
assert_report "$VERIFICATION_ID" '.schemaCount == 1'
assert_report "$VERIFICATION_ID" '(.tableStats | map(.name) | sort) == ["t_a","t_b"]'
echo "Success report OK (pg${PG_VERSION}): COMPLETED with tableCount=2 schemaCount=1 t_a+t_b present"
stop_agent
if ! leak_check "$AGENT_ID"; then
echo "---- agent.out ----"
cat agent.out
exit 1
fi
echo "Verification agent restore-success e2e (pg${PG_VERSION}) passed"
@@ -0,0 +1,61 @@
#!/bin/bash
# TimescaleDB restore e2e: the mock serves a hypertable -Fc dump and a claim
# whose timescaledbVersion is set, so the agent must spawn a version-matched
# timescale/timescaledb:<ver>-pg<major> container, run timescaledb_pre_restore /
# timescaledb_post_restore around a single-threaded pg_restore, then the
# verifier must collect stats and the agent must POST COMPLETED with exit 0.
# Without the hooks (or with parallel -j) the _timescaledb_catalog restore fails
# and the agent would report FAILED instead.
set -euo pipefail
source "$(dirname "$0")/lib.sh"
PG_VERSION="17"
TS_VERSION="2.17.0"
WORK="/tmp/agent-work-restore-timescale"
AGENT_ID="55555555-5555-5555-5555-555555555555"
VERIFICATION_ID="55555555-aaaa-aaaa-aaaa-555555555555"
BACKUP_ID="55555555-bbbb-bbbb-bbbb-555555555555"
rm -rf "$WORK"
mkdir -p "$WORK"
cd "$WORK"
reset_mock_state
reset_mock_version
cp "$ARTIFACTS/agent-v1" ./databasus-verification-agent
chmod +x ./databasus-verification-agent
start_agent "$AGENT_ID"
curl -sf -X POST "$MOCK/mock/set-backup-fixture" \
-H 'Content-Type: application/json' \
-d '{"path":"/artifacts/good-timescale-pg17.dump"}'
curl -sf -X POST "$MOCK/mock/set-claim" \
-H 'Content-Type: application/json' \
-d "{\"verificationId\":\"$VERIFICATION_ID\",\"backupId\":\"$BACKUP_ID\",\"backupSizeMb\":1,\"maxContainerDiskMb\":4096,\"timescaledbVersion\":\"${TS_VERSION}\",\"database\":{\"type\":\"POSTGRES_LOGICAL\",\"postgresqlLogical\":{\"version\":\"${PG_VERSION}\"}}}"
wait_for_report '"status":"COMPLETED"' 300 '"status":"FAILED"'
assert_report "$VERIFICATION_ID" '.pgRestoreExitCode == 0'
assert_report "$VERIFICATION_ID" '.dbSizeBytesAfterRestore > 0'
# The hypertable's parent table is restored into public...
assert_report "$VERIFICATION_ID" '(.tableStats | map(.name) | any(. == "sensor_data"))'
# ...and its data lands in _timescaledb_internal chunks, proving the catalog +
# chunk restore (the part that fails without the pre/post hooks) succeeded.
assert_report "$VERIFICATION_ID" '(.tableStats | map(select(.name | startswith("_hyper_")) | .rowCount) | add) > 0'
echo "TimescaleDB restore report OK: COMPLETED with sensor_data hypertable + chunk rows restored"
stop_agent
if ! leak_check "$AGENT_ID"; then
echo "---- agent.out ----"
cat agent.out
exit 1
fi
echo "Verification agent restore-timescale e2e passed"
@@ -0,0 +1,131 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/lib.sh"
WORK="/tmp/agent-work-startstop"
AGENT_ID="22222222-2222-2222-2222-222222222222"
dump_daemon_log() {
echo "---- databasus-verification.daemon.log ----"
cat databasus-verification.daemon.log 2>/dev/null || echo "(no daemon log)"
}
rm -rf "$WORK"
mkdir -p "$WORK"
cd "$WORK"
reset_mock_version
cp "$ARTIFACTS/agent-v1" ./databasus-verification-agent
chmod +x ./databasus-verification-agent
LAUNCH_FLAGS=(
--databasus-host "$MOCK"
--agent-id "$AGENT_ID"
--token test-token
--max-cpu 4
--max-ram-mb 2048
--max-disk-gb 20
--max-concurrent-jobs 2
--allow-insecure-http
--skip-update
)
# 1) status before start -> not running.
STATUS_OUT="$(./databasus-verification-agent status)"
echo "status (before): $STATUS_OUT"
if ! echo "$STATUS_OUT" | grep -q "Agent is not running"; then
echo "FAIL: expected 'Agent is not running' before start"
exit 1
fi
# 2) start detaches, returns promptly, prints the daemon PID.
START_OUT="$(./databasus-verification-agent start "${LAUNCH_FLAGS[@]}")"
echo "start: $START_OUT"
if ! echo "$START_OUT" | grep -q "Agent started in background (PID "; then
echo "FAIL: start did not report a background PID"
dump_daemon_log
exit 1
fi
# 3) start created the single-instance lock file.
if [ ! -f databasus-verification.lock ]; then
echo "FAIL: databasus-verification.lock not created"
dump_daemon_log
exit 1
fi
# 4) the detached daemon heartbeats.
RESUMED=0
for _ in $(seq 1 20); do
COUNT="$(hb_count || echo 0)"
if [ "${COUNT:-0}" -ge 1 ]; then
RESUMED=1
break
fi
sleep 1
done
if [ "$RESUMED" -ne 1 ]; then
echo "FAIL: detached daemon did not heartbeat after start"
dump_daemon_log
exit 1
fi
echo "Daemon heartbeating"
# 5) status -> running with a live PID.
STATUS_OUT="$(./databasus-verification-agent status)"
echo "status (running): $STATUS_OUT"
if ! echo "$STATUS_OUT" | grep -q "Agent is running (PID "; then
echo "FAIL: status did not report running"
dump_daemon_log
exit 1
fi
PID="$(echo "$STATUS_OUT" | sed -n 's/.*PID \([0-9]*\).*/\1/p')"
if ! kill -0 "$PID" 2>/dev/null; then
echo "FAIL: reported PID $PID is not alive"
exit 1
fi
# 6) a second agent in the same directory is rejected by the lock. `run` is the
# foreground command, so the lock error surfaces directly on its stderr.
set +e
SECOND_OUT="$(./databasus-verification-agent run "${LAUNCH_FLAGS[@]}" 2>&1)"
SECOND_RC=$?
set -e
echo "second run: $SECOND_OUT"
if [ "$SECOND_RC" -eq 0 ]; then
echo "FAIL: a second agent started while one was already running"
exit 1
fi
if ! echo "$SECOND_OUT" | grep -q "another instance is already running"; then
echo "FAIL: second agent did not fail with the single-instance error"
exit 1
fi
echo "Second instance rejected"
# 7) stop terminates the daemon within the stop timeout.
./databasus-verification-agent stop
GONE=0
for _ in $(seq 1 35); do
if ! kill -0 "$PID" 2>/dev/null; then
GONE=1
break
fi
sleep 1
done
if [ "$GONE" -ne 1 ]; then
echo "FAIL: agent (PID $PID) still alive after stop"
exit 1
fi
echo "Agent stopped"
# 8) status after stop -> not running again.
STATUS_OUT="$(./databasus-verification-agent status)"
echo "status (after): $STATUS_OUT"
if ! echo "$STATUS_OUT" | grep -q "Agent is not running"; then
echo "FAIL: expected 'Agent is not running' after stop"
exit 1
fi
echo "Verification agent start/stop/status e2e passed"
@@ -0,0 +1,61 @@
#!/bin/bash
# Startup purge: at boot the agent removes every container labeled with its
# agent_id — the single-agent invariant means no other process owns containers
# with the same agent_id, so blanket removal is safe. Pre-seed a stray, start
# the agent, assert it's gone.
set -euo pipefail
source "$(dirname "$0")/lib.sh"
WORK="/tmp/agent-work-purge"
AGENT_ID="66666666-6666-6666-6666-666666666666"
STRAY_NAME="databasus-verif-purge-stray"
STRAY_LABEL="databasus.verification.agent_id=${AGENT_ID}"
rm -rf "$WORK"
mkdir -p "$WORK"
cd "$WORK"
reset_mock_state
reset_mock_version
docker rm -f "$STRAY_NAME" >/dev/null 2>&1 || true
docker run -d --name "$STRAY_NAME" --label "$STRAY_LABEL" alpine sleep 600 >/dev/null
if ! docker ps -a --filter "label=$STRAY_LABEL" --format '{{.Names}}' | grep -q "$STRAY_NAME"; then
echo "FAIL: pre-seed stray container not present"
exit 1
fi
echo "Pre-seeded stray container $STRAY_NAME with label $STRAY_LABEL"
cp "$ARTIFACTS/agent-v1" ./databasus-verification-agent
chmod +x ./databasus-verification-agent
start_agent "$AGENT_ID"
GONE=0
for _ in $(seq 1 30); do
if [ -z "$(docker ps -a --filter "label=$STRAY_LABEL" --format '{{.Names}}')" ]; then
GONE=1
break
fi
sleep 1
done
if [ "$GONE" -ne 1 ]; then
echo "FAIL: stray container still present after agent start; PurgeContainers did not run"
docker ps -a --filter "label=$STRAY_LABEL"
echo "---- agent.out (last 30) ----"
tail -30 agent.out
stop_agent
exit 1
fi
echo "Stray container purged by agent startup"
stop_agent
if ! leak_check "$AGENT_ID"; then
exit 1
fi
echo "Verification agent startup-purge e2e passed"
+41
View File
@@ -0,0 +1,41 @@
module databasus-verification-agent
go 1.26.3
require (
github.com/go-resty/resty/v2 v2.17.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
github.com/moby/moby/api v1.54.2
github.com/moby/moby/client v0.4.1
github.com/stretchr/testify v1.11.1
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+95
View File
@@ -0,0 +1,95 @@
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
@@ -0,0 +1,71 @@
# syntax=docker/dockerfile:1
# Databasus verification image: the official Postgres image plus a curated set of
# common extensions, so a dump's CREATE EXTENSION statements succeed when the
# agent restores a backup for verification (issue #619 PostGIS, plus auxiliary
# extensions like pg_stat_kcache / set_user). This mirrors how the agent already
# uses timescale/timescaledb for TimescaleDB. Built per PG major and published as
# databasus/verification-postgres:<major>.
#
# BASE_IMAGE is digest-pinned by CI (postgres:<major>@sha256:...). The tag-only
# default exists solely for local `docker build --build-arg PG_MAJOR=16` runs.
ARG PG_MAJOR=16
ARG BASE_IMAGE=postgres:${PG_MAJOR}
FROM ${BASE_IMAGE}
# ARGs declared before FROM are not visible in the build stage — redeclare it.
ARG PG_MAJOR
# Extensions whose CREATE EXTENSION works once the files are present, plus the
# Tier-2 monitoring extensions that additionally need shared_preload_libraries
# (set below). The official image already configures the apt.postgresql.org repo
# these packages come from, so every entry must be installable from PGDG apt for
# all majors 12-18 (verified). A missing package fails the build loudly rather
# than silently shipping a half-populated image — important because the agent
# tolerates missing extensions at restore time, so a thinned image would mark
# such backups "verified" without actually exercising the extension.
#
# Intentionally NOT bundled: topn (Citus), pg_hashids, anonymizer
# (postgresql_anonymizer) and pg_stat_monitor (Percona). PGDG apt does not
# package these — they need source builds or Percona's own repo. Add them via
# that route (not this list) if verification coverage for them is ever required.
ARG EXTENSION_PACKAGES="\
postgresql-${PG_MAJOR}-postgis-3 \
postgresql-${PG_MAJOR}-pgrouting \
postgresql-${PG_MAJOR}-h3 \
postgresql-${PG_MAJOR}-pgvector \
postgresql-${PG_MAJOR}-rum \
postgresql-${PG_MAJOR}-partman \
postgresql-${PG_MAJOR}-repack \
postgresql-${PG_MAJOR}-hypopg \
postgresql-${PG_MAJOR}-hll \
postgresql-${PG_MAJOR}-set-user \
postgresql-${PG_MAJOR}-pgaudit \
postgresql-${PG_MAJOR}-ip4r \
postgresql-${PG_MAJOR}-semver \
postgresql-${PG_MAJOR}-http \
postgresql-${PG_MAJOR}-mysql-fdw \
postgresql-${PG_MAJOR}-tds-fdw \
postgresql-${PG_MAJOR}-pg-stat-kcache \
postgresql-${PG_MAJOR}-pg-qualstats \
postgresql-${PG_MAJOR}-pg-wait-sampling \
postgresql-plpython3-${PG_MAJOR} \
postgresql-plperl-${PG_MAJOR} \
postgresql-pltcl-${PG_MAJOR}"
# Tier-2 extensions refuse to CREATE unless their library is preloaded. This list
# must match the packages installed above: a name whose library is absent makes
# Postgres refuse to start. Appending to the initdb sample means every fresh
# PGDATA (the verification container is ephemeral) inherits it; the agent does not
# pass -c shared_preload_libraries, so this value stands at runtime.
ARG PRELOAD_LIBRARIES="pg_stat_statements,pg_stat_kcache,pg_qualstats,pg_wait_sampling,pgaudit,set_user"
# Root is the base image's user; the official entrypoint still gosu-drops to the
# postgres user before exec, so the agent's hardened runtime is unchanged.
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends ${EXTENSION_PACKAGES}; \
rm -rf /var/lib/apt/lists/*; \
printf "shared_preload_libraries = '%s'\n" "${PRELOAD_LIBRARIES}" \
>> "/usr/share/postgresql/${PG_MAJOR}/postgresql.conf.sample"
@@ -0,0 +1,244 @@
package config
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"net/url"
"os"
"strings"
"databasus-verification-agent/internal/logger"
)
var log = logger.GetLogger()
const configFileName = "databasus-verification.json"
const minRAMMbPerJob = 512
// DefaultVerificationPgImageRepo bundles common extensions so a dump's CREATE
// EXTENSION statements succeed during verification; override via
// verificationPgImageRepo (e.g. a private registry, or "postgres" for stock).
const DefaultVerificationPgImageRepo = "databasus/verification-postgres"
type Config struct {
DatabasusHost string `json:"databasusHost"`
AgentID string `json:"agentId"`
Token string `json:"token"`
MaxCPU int `json:"maxCpu"`
MaxRAMMb int `json:"maxRamMb"`
MaxDiskGb int `json:"maxDiskGb"`
MaxConcurrentJobs int `json:"maxConcurrentJobs"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
VerificationPgImageRepo string `json:"verificationPgImageRepo"`
flagSources map[string]string
}
func (c *Config) GetVerificationPgImageRepo() string {
if c.VerificationPgImageRepo == "" {
return DefaultVerificationPgImageRepo
}
return c.VerificationPgImageRepo
}
type Capacity struct {
MaxCPU int
MaxRAMMb int
MaxDiskGb int
MaxConcurrentJobs int
CPUPerJob int
RAMMbPerJob int
}
// LoadFromJSONAndArgs reads databasus-verification.json into the struct
// and overrides JSON values with any explicitly provided CLI flags.
func (c *Config) LoadFromJSONAndArgs(fs *flag.FlagSet, args []string) {
c.loadFromJSON()
bindings := c.flagBindings()
c.flagSources = sourcesFromConfig(bindings, c)
appliers := make([]func(*Config, map[string]string), 0, len(bindings))
for _, binding := range bindings {
appliers = append(appliers, binding.register(fs))
}
if err := fs.Parse(args); err != nil {
os.Exit(1)
}
for _, apply := range appliers {
apply(c, c.flagSources)
}
log.Info("========= Loading config ============")
c.logConfigSources(bindings)
log.Info("========= Config has been loaded ====")
}
// SaveToJSON writes the current struct to databasus-verification.json.
func (c *Config) SaveToJSON() error {
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(configFileName, data, 0o644)
}
// LoadFromJSON reads databasus-verification.json without applying CLI flags.
// The detached _run daemon uses this: the parent `start` already validated and
// persisted the config, so the child trusts the file as-is.
func (c *Config) LoadFromJSON() {
c.loadFromJSON()
}
func (c *Config) Validate() (Capacity, error) {
if c.DatabasusHost == "" {
return Capacity{}, fmt.Errorf("databasus-host is required")
}
if c.AgentID == "" {
return Capacity{}, fmt.Errorf("agent-id is required")
}
if c.Token == "" {
return Capacity{}, fmt.Errorf("token is required")
}
return c.DeriveCapacity()
}
func (c *Config) DeriveCapacity() (Capacity, error) {
if c.MaxConcurrentJobs < 1 {
return Capacity{}, fmt.Errorf(
"max-concurrent-jobs must be >= 1 (got %d)", c.MaxConcurrentJobs)
}
if c.MaxCPU < 1 {
return Capacity{}, fmt.Errorf("max-cpu must be >= 1 (got %d)", c.MaxCPU)
}
if c.MaxDiskGb < 1 {
return Capacity{}, fmt.Errorf("max-disk-gb must be >= 1 (got %d)", c.MaxDiskGb)
}
if c.MaxRAMMb < minRAMMbPerJob {
return Capacity{}, fmt.Errorf(
"max-ram-mb must be >= %d (got %d)", minRAMMbPerJob, c.MaxRAMMb)
}
cpuPerJob := c.MaxCPU / c.MaxConcurrentJobs
ramMbPerJob := c.MaxRAMMb / c.MaxConcurrentJobs
if cpuPerJob < 1 {
return Capacity{}, fmt.Errorf(
"max-cpu (%d) split across max-concurrent-jobs (%d) yields < 1 CPU per job; "+
"lower max-concurrent-jobs or raise max-cpu",
c.MaxCPU, c.MaxConcurrentJobs)
}
if ramMbPerJob < minRAMMbPerJob {
return Capacity{}, fmt.Errorf(
"max-ram-mb (%d) split across max-concurrent-jobs (%d) yields %d MB per job, "+
"below the %d MB floor; lower max-concurrent-jobs or raise max-ram-mb",
c.MaxRAMMb, c.MaxConcurrentJobs, ramMbPerJob, minRAMMbPerJob)
}
return Capacity{
MaxCPU: c.MaxCPU,
MaxRAMMb: c.MaxRAMMb,
MaxDiskGb: c.MaxDiskGb,
MaxConcurrentJobs: c.MaxConcurrentJobs,
CPUPerJob: cpuPerJob,
RAMMbPerJob: ramMbPerJob,
}, nil
}
// ValidateTransport enforces the http/https gate before any goroutine starts.
// The per-agent token and the decrypted backup stream both cross this link, so
// plain HTTP is allowed only with explicit operator consent.
func (c *Config) ValidateTransport(isStdinTTY bool, in *bufio.Reader) error {
parsed, err := url.Parse(c.DatabasusHost)
if err != nil {
return fmt.Errorf("databasus-host is not a valid URL: %w", err)
}
switch parsed.Scheme {
case "https":
return nil
case "http":
return c.consentToInsecureHTTP(isStdinTTY, in)
default:
return fmt.Errorf(
"databasus-host must start with https:// or http:// (got scheme %q)", parsed.Scheme)
}
}
func (c *Config) loadFromJSON() {
data, err := os.ReadFile(configFileName)
if err != nil {
if os.IsNotExist(err) {
log.Info("No databasus-verification.json found, will create on save")
return
}
log.Warn("Failed to read databasus-verification.json", "error", err)
return
}
if err := json.Unmarshal(data, c); err != nil {
log.Warn("Failed to parse databasus-verification.json", "error", err)
return
}
log.Info("Configuration loaded from " + configFileName)
}
func (c *Config) consentToInsecureHTTP(isStdinTTY bool, in *bufio.Reader) error {
if c.AllowInsecureHTTP {
log.Warn("databasus-host is plain HTTP; transport is unencrypted")
return nil
}
if !isStdinTTY {
return fmt.Errorf(
"refusing to use plain HTTP over a non-interactive connection: " +
"switch --databasus-host to https:// or pass --allow-insecure-http " +
"to accept unencrypted transport")
}
fmt.Fprint(os.Stderr,
"WARNING: connecting to the primary over plain HTTP, not HTTPS. "+
"The agent token and decrypted backup data are sent unencrypted. "+
"Continue? [y/N] ")
answer, _ := in.ReadString('\n')
answer = strings.ToLower(strings.TrimSpace(answer))
if answer != "y" && answer != "yes" {
return fmt.Errorf("aborted: plain HTTP transport declined by operator")
}
return nil
}
func maskSensitive(value string) string {
if value == "" {
return "(not set)"
}
visibleLen := max(len(value)/4, 1)
return value[:visibleLen] + "***"
}
@@ -0,0 +1,290 @@
package config
import (
"bufio"
"flag"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func loadConfig(t *testing.T, args ...string) *Config {
t.Helper()
cfg := &Config{}
cfg.LoadFromJSONAndArgs(flag.NewFlagSet("test", flag.ContinueOnError), args)
return cfg
}
var allFlagNames = []string{
"databasus-host", "agent-id", "token", "max-cpu", "max-ram-mb",
"max-disk-gb", "max-concurrent-jobs", "allow-insecure-http", "verification-pg-image-repo",
}
func emptyReader() *bufio.Reader {
return bufio.NewReader(strings.NewReader(""))
}
func Test_Validate_WhenConfigComplete_ReturnsCapacity(t *testing.T) {
cfg := &Config{
DatabasusHost: "https://primary.example:4005",
AgentID: "agent-1",
Token: "secret",
MaxCPU: 8,
MaxRAMMb: 4096,
MaxDiskGb: 100,
MaxConcurrentJobs: 4,
}
capacity, err := cfg.Validate()
require.NoError(t, err)
assert.Equal(t, 2, capacity.CPUPerJob)
assert.Equal(t, 1024, capacity.RAMMbPerJob)
}
func Test_Validate_WhenRequiredStringMissing_ReturnsError(t *testing.T) {
for name, cfg := range map[string]*Config{
"no databasus-host": {AgentID: "a", Token: "t", MaxCPU: 4, MaxRAMMb: 2048, MaxDiskGb: 10, MaxConcurrentJobs: 1},
"no agent-id": {DatabasusHost: "https://x:4005", Token: "t", MaxCPU: 4, MaxRAMMb: 2048, MaxDiskGb: 10, MaxConcurrentJobs: 1},
"no token": {DatabasusHost: "https://x:4005", AgentID: "a", MaxCPU: 4, MaxRAMMb: 2048, MaxDiskGb: 10, MaxConcurrentJobs: 1},
} {
t.Run(name, func(t *testing.T) {
_, err := cfg.Validate()
require.Error(t, err)
})
}
}
func Test_Validate_WhenStringsSetButCapacityInvalid_ReturnsCapacityError(t *testing.T) {
cfg := &Config{
DatabasusHost: "https://primary.example:4005",
AgentID: "agent-1",
Token: "secret",
MaxCPU: 2,
MaxRAMMb: 4096,
MaxDiskGb: 100,
MaxConcurrentJobs: 4,
}
_, err := cfg.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "CPU per job")
}
func Test_DeriveCapacity_WhenConfigValid_DerivesPerJobSplit(t *testing.T) {
cfg := &Config{MaxCPU: 8, MaxRAMMb: 4096, MaxDiskGb: 100, MaxConcurrentJobs: 4}
capacity, err := cfg.DeriveCapacity()
require.NoError(t, err)
assert.Equal(t, 2, capacity.CPUPerJob)
assert.Equal(t, 1024, capacity.RAMMbPerJob)
assert.Equal(t, 8, capacity.MaxCPU)
assert.Equal(t, 4096, capacity.MaxRAMMb)
assert.Equal(t, 100, capacity.MaxDiskGb)
assert.Equal(t, 4, capacity.MaxConcurrentJobs)
}
func Test_DeriveCapacity_WhenConcurrentJobsExceedCPU_ReturnsError(t *testing.T) {
cfg := &Config{MaxCPU: 2, MaxRAMMb: 4096, MaxDiskGb: 100, MaxConcurrentJobs: 4}
_, err := cfg.DeriveCapacity()
require.Error(t, err)
assert.Contains(t, err.Error(), "CPU per job")
}
func Test_DeriveCapacity_WhenRAMBelowFloor_ReturnsError(t *testing.T) {
cfg := &Config{MaxCPU: 4, MaxRAMMb: 256, MaxDiskGb: 100, MaxConcurrentJobs: 1}
_, err := cfg.DeriveCapacity()
require.Error(t, err)
assert.Contains(t, err.Error(), "max-ram-mb")
}
func Test_DeriveCapacity_WhenRAMPerJobBelowFloor_ReturnsError(t *testing.T) {
cfg := &Config{MaxCPU: 8, MaxRAMMb: 1024, MaxDiskGb: 100, MaxConcurrentJobs: 8}
_, err := cfg.DeriveCapacity()
require.Error(t, err)
assert.Contains(t, err.Error(), "per job")
}
func Test_DeriveCapacity_WhenZeroFields_ReturnsError(t *testing.T) {
for name, cfg := range map[string]*Config{
"no concurrent jobs": {MaxCPU: 4, MaxRAMMb: 2048, MaxDiskGb: 10, MaxConcurrentJobs: 0},
"no cpu": {MaxCPU: 0, MaxRAMMb: 2048, MaxDiskGb: 10, MaxConcurrentJobs: 1},
"no disk": {MaxCPU: 4, MaxRAMMb: 2048, MaxDiskGb: 0, MaxConcurrentJobs: 1},
} {
t.Run(name, func(t *testing.T) {
_, err := cfg.DeriveCapacity()
require.Error(t, err)
})
}
}
func Test_ValidateTransport_WhenHTTPS_PassesWithoutPrompt(t *testing.T) {
cfg := &Config{DatabasusHost: "https://primary.example:4005"}
err := cfg.ValidateTransport(false, emptyReader())
require.NoError(t, err)
}
func Test_ValidateTransport_WhenHTTPNonTTYWithoutFlag_FailsNamingBothFixes(t *testing.T) {
cfg := &Config{DatabasusHost: "http://primary.example:4005"}
err := cfg.ValidateTransport(false, emptyReader())
require.Error(t, err)
assert.Contains(t, err.Error(), "https://")
assert.Contains(t, err.Error(), "--allow-insecure-http")
}
func Test_ValidateTransport_WhenHTTPWithAllowFlag_PassesWithWarn(t *testing.T) {
cfg := &Config{DatabasusHost: "http://primary.example:4005", AllowInsecureHTTP: true}
err := cfg.ValidateTransport(false, emptyReader())
require.NoError(t, err)
}
func Test_ValidateTransport_WhenHTTPTTYAndOperatorConsents_Passes(t *testing.T) {
cfg := &Config{DatabasusHost: "http://primary.example:4005"}
err := cfg.ValidateTransport(true, bufio.NewReader(strings.NewReader("y\n")))
require.NoError(t, err)
}
func Test_ValidateTransport_WhenHTTPTTYAndOperatorDeclines_ReturnsError(t *testing.T) {
cfg := &Config{DatabasusHost: "http://primary.example:4005"}
err := cfg.ValidateTransport(true, bufio.NewReader(strings.NewReader("n\n")))
require.Error(t, err)
}
func Test_ValidateTransport_WhenSchemeUnsupported_ReturnsError(t *testing.T) {
cfg := &Config{DatabasusHost: "ftp://primary.example:4005"}
err := cfg.ValidateTransport(true, emptyReader())
require.Error(t, err)
assert.Contains(t, err.Error(), "https://")
}
func Test_MaskSensitive_WhenEmpty_ReturnsNotSet(t *testing.T) {
assert.Equal(t, "(not set)", maskSensitive(""))
}
func Test_MaskSensitive_WhenValue_RevealsQuarterThenMasks(t *testing.T) {
assert.Equal(t, "ab***", maskSensitive("abcdefgh"))
}
func Test_GetVerificationPgImageRepo_WhenUnset_ReturnsBundledDefault(t *testing.T) {
cfg := &Config{}
assert.Equal(t, DefaultVerificationPgImageRepo, cfg.GetVerificationPgImageRepo())
}
func Test_GetVerificationPgImageRepo_WhenSet_ReturnsConfiguredValue(t *testing.T) {
cfg := &Config{VerificationPgImageRepo: "registry.internal/pg-verify"}
assert.Equal(t, "registry.internal/pg-verify", cfg.GetVerificationPgImageRepo())
}
func Test_LoadFromJSONAndArgs_WhenFlagsProvided_OverrideEveryValue(t *testing.T) {
t.Chdir(t.TempDir())
cfg := loadConfig(t,
"--databasus-host", "https://flag:4005",
"--agent-id", "agent-x",
"--token", "tok",
"--max-cpu", "8",
"--max-ram-mb", "4096",
"--max-disk-gb", "100",
"--max-concurrent-jobs", "4",
"--allow-insecure-http",
"--verification-pg-image-repo", "registry/pg",
)
assert.Equal(t, "https://flag:4005", cfg.DatabasusHost)
assert.Equal(t, "agent-x", cfg.AgentID)
assert.Equal(t, "tok", cfg.Token)
assert.Equal(t, 8, cfg.MaxCPU)
assert.Equal(t, 4096, cfg.MaxRAMMb)
assert.Equal(t, 100, cfg.MaxDiskGb)
assert.Equal(t, 4, cfg.MaxConcurrentJobs)
assert.True(t, cfg.AllowInsecureHTTP)
assert.Equal(t, "registry/pg", cfg.VerificationPgImageRepo)
for _, name := range allFlagNames {
assert.Equal(t, "command line args", cfg.flagSources[name], name)
}
}
func Test_LoadFromJSONAndArgs_WhenOnlyFilePresent_LoadsFileAndMarksSource(t *testing.T) {
t.Chdir(t.TempDir())
source := &Config{DatabasusHost: "https://file:4005", AgentID: "agent-file", MaxCPU: 6}
require.NoError(t, source.SaveToJSON())
cfg := loadConfig(t)
assert.Equal(t, "https://file:4005", cfg.DatabasusHost)
assert.Equal(t, "agent-file", cfg.AgentID)
assert.Equal(t, 6, cfg.MaxCPU)
assert.Equal(t, configFileName, cfg.flagSources["databasus-host"])
}
func Test_LoadFromJSONAndArgs_WhenFlagAndFile_FlagOverridesFile(t *testing.T) {
t.Chdir(t.TempDir())
source := &Config{DatabasusHost: "https://file:4005", AgentID: "agent-file"}
require.NoError(t, source.SaveToJSON())
cfg := loadConfig(t, "--databasus-host", "https://flag:4005")
assert.Equal(t, "https://flag:4005", cfg.DatabasusHost, "flag overrides file")
assert.Equal(t, "agent-file", cfg.AgentID, "untouched field keeps the file value")
assert.Equal(t, "command line args", cfg.flagSources["databasus-host"])
assert.Equal(t, configFileName, cfg.flagSources["agent-id"])
}
func Test_LoadFromJSONAndArgs_WhenZeroValuedFlags_DoNotOverrideFile(t *testing.T) {
t.Chdir(t.TempDir())
source := &Config{MaxCPU: 8, VerificationPgImageRepo: "registry/pg"}
require.NoError(t, source.SaveToJSON())
cfg := loadConfig(t, "--max-cpu", "0", "--verification-pg-image-repo", "")
assert.Equal(t, 8, cfg.MaxCPU, "an explicit zero flag cannot unset a file value")
assert.Equal(t, "registry/pg", cfg.VerificationPgImageRepo)
}
func Test_LoadFromJSONAndArgs_WhenAllowInsecureHTTPInFile_StaysTrueWithoutFlag(t *testing.T) {
t.Chdir(t.TempDir())
source := &Config{AllowInsecureHTTP: true}
require.NoError(t, source.SaveToJSON())
cfg := loadConfig(t)
assert.True(t, cfg.AllowInsecureHTTP)
assert.Equal(t, configFileName, cfg.flagSources["allow-insecure-http"])
}
func Test_LoadFromJSONAndArgs_WhenNothingProvided_AllSourcesNotConfigured(t *testing.T) {
t.Chdir(t.TempDir())
cfg := loadConfig(t)
for _, name := range allFlagNames {
assert.Equal(t, "not configured", cfg.flagSources[name], name)
}
}
+166
View File
@@ -0,0 +1,166 @@
package config
import "flag"
const (
sourceNotConfigured = "not configured"
sourceCommandLine = "command line args"
)
// flagBinding ties one CLI flag to its Config field for the whole lifecycle —
// registration, applying an explicitly-set value, source attribution, and
// logging. Collecting these per flag keeps adding a flag a one-entry change
// instead of edits across four parallel blocks that silently drift apart.
type flagBinding struct {
name string
usage string
// register declares the flag on fs and returns an applier that copies an
// explicitly-set value onto c and records the command-line source. A
// zero/empty flag is treated as "not provided" — it never overrides nor
// unsets a value already loaded from JSON.
register func(fs *flag.FlagSet) func(c *Config, sources map[string]string)
// isSet reports whether c already carries a value (loaded from JSON).
isSet func(c *Config) bool
// value is the loggable value, masked for secrets.
value func(c *Config) any
}
func stringBinding(
name, usage string,
get func(*Config) string,
set func(*Config, string),
) flagBinding {
return flagBinding{
name: name,
usage: usage,
register: func(fs *flag.FlagSet) func(*Config, map[string]string) {
value := fs.String(name, "", usage)
return func(c *Config, sources map[string]string) {
if *value != "" {
set(c, *value)
sources[name] = sourceCommandLine
}
}
},
isSet: func(c *Config) bool { return get(c) != "" },
value: func(c *Config) any { return get(c) },
}
}
func secretBinding(
name, usage string,
get func(*Config) string,
set func(*Config, string),
) flagBinding {
binding := stringBinding(name, usage, get, set)
binding.value = func(c *Config) any { return maskSensitive(get(c)) }
return binding
}
func intBinding(
name, usage string,
get func(*Config) int,
set func(*Config, int),
) flagBinding {
return flagBinding{
name: name,
usage: usage,
register: func(fs *flag.FlagSet) func(*Config, map[string]string) {
value := fs.Int(name, 0, usage)
return func(c *Config, sources map[string]string) {
if *value != 0 {
set(c, *value)
sources[name] = sourceCommandLine
}
}
},
isSet: func(c *Config) bool { return get(c) != 0 },
value: func(c *Config) any { return get(c) },
}
}
// boolBinding latches: the flag only ever turns the value on, so a persisted
// true is never flipped back off by its absence on the command line.
func boolBinding(
name, usage string,
get func(*Config) bool,
set func(*Config, bool),
) flagBinding {
return flagBinding{
name: name,
usage: usage,
register: func(fs *flag.FlagSet) func(*Config, map[string]string) {
value := fs.Bool(name, false, usage)
return func(c *Config, sources map[string]string) {
if *value {
set(c, true)
sources[name] = sourceCommandLine
}
}
},
isSet: func(c *Config) bool { return get(c) },
value: func(c *Config) any { return get(c) },
}
}
func (c *Config) flagBindings() []flagBinding {
return []flagBinding{
stringBinding("databasus-host", "Databasus server URL (e.g. https://your-server:4005)",
func(c *Config) string { return c.DatabasusHost },
func(c *Config, v string) { c.DatabasusHost = v }),
stringBinding("agent-id", "Verification agent ID",
func(c *Config) string { return c.AgentID },
func(c *Config, v string) { c.AgentID = v }),
secretBinding("token", "Verification agent token",
func(c *Config) string { return c.Token },
func(c *Config, v string) { c.Token = v }),
intBinding("max-cpu", "Total CPU cores available to the agent",
func(c *Config) int { return c.MaxCPU },
func(c *Config, v int) { c.MaxCPU = v }),
intBinding("max-ram-mb", "Total RAM in MB available to the agent",
func(c *Config) int { return c.MaxRAMMb },
func(c *Config, v int) { c.MaxRAMMb = v }),
intBinding("max-disk-gb", "Total scratch disk in GB available to the agent",
func(c *Config) int { return c.MaxDiskGb },
func(c *Config, v int) { c.MaxDiskGb = v }),
intBinding("max-concurrent-jobs", "Number of verifications to run in parallel",
func(c *Config) int { return c.MaxConcurrentJobs },
func(c *Config, v int) { c.MaxConcurrentJobs = v }),
boolBinding("allow-insecure-http",
"Permit a plain http:// databasus-host (token and backup data sent unencrypted)",
func(c *Config) bool { return c.AllowInsecureHTTP },
func(c *Config, v bool) { c.AllowInsecureHTTP = v }),
stringBinding("verification-pg-image-repo",
"Container image repo bundling PostgreSQL extensions for verification "+
"(default "+DefaultVerificationPgImageRepo+"; set to 'postgres' for the stock image)",
func(c *Config) string { return c.VerificationPgImageRepo },
func(c *Config, v string) { c.VerificationPgImageRepo = v }),
}
}
func sourcesFromConfig(bindings []flagBinding, c *Config) map[string]string {
sources := make(map[string]string, len(bindings))
for _, binding := range bindings {
if binding.isSet(c) {
sources[binding.name] = configFileName
} else {
sources[binding.name] = sourceNotConfigured
}
}
return sources
}
func (c *Config) logConfigSources(bindings []flagBinding) {
for _, binding := range bindings {
log.Info(binding.name, "value", binding.value(c), "source", c.flagSources[binding.name])
}
}
@@ -0,0 +1,315 @@
package api
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/go-resty/resty/v2"
"github.com/google/uuid"
)
// reportRetryDelay returns the exponential backoff for report attempt n
// (1-based): 1s,2s,4s,8s,16s,32s, capped at maxBackoff. The exponent is
// clamped before the shift so a large attempt count cannot overflow.
func reportRetryDelay(attempt int) time.Duration {
return expBackoff(attempt - 1)
}
func expBackoff(shift int) time.Duration {
const maxShift = 5 // 2^5 s = 32s = maxBackoff
if shift < 0 {
shift = 0
}
if shift >= maxShift {
return maxBackoff
}
d := time.Duration(1<<shift) * time.Second
if d > maxBackoff {
return maxBackoff
}
return d
}
type Client struct {
json *resty.Client
noRetry *resty.Client
streamHTTP *http.Client
host string
token string
agentID string
log *slog.Logger
}
func NewClient(host, token, agentID string, log *slog.Logger) *Client {
setAuth := func(_ *resty.Client, req *resty.Request) error {
if h := bearerHeader(token); h != "" {
req.SetHeader("Authorization", h)
}
return nil
}
jsonClient := resty.New().
SetTimeout(apiCallTimeout).
SetRetryCount(maxRetryAttempts - 1).
SetRetryWaitTime(retryBaseDelay).
SetRetryMaxWaitTime(4 * retryBaseDelay).
AddRetryCondition(func(resp *resty.Response, err error) bool {
return err != nil || resp.StatusCode() >= 500
}).
SetHeaders(noCacheHeaders).
OnBeforeRequest(setAuth)
// Claim and report own their retry policy explicitly (claim: loop retries
// forever; report: bounded retry-with-deadline). A retrying client here
// would make "exactly N retries / give up at the budget" untestable.
noRetryClient := resty.New().
SetTimeout(apiCallTimeout).
SetHeaders(noCacheHeaders).
OnBeforeRequest(setAuth)
return &Client{
json: jsonClient,
noRetry: noRetryClient,
streamHTTP: &http.Client{},
host: host,
token: token,
agentID: agentID,
log: log,
}
}
func (c *Client) FetchServerVersion(ctx context.Context) (string, error) {
var ver versionResponse
httpResp, err := c.json.R().
SetContext(ctx).
SetResult(&ver).
Get(c.buildURL(versionPath))
if err != nil {
return "", err
}
if err := c.checkResponse(httpResp, "fetch server version"); err != nil {
return "", err
}
return ver.Version, nil
}
func (c *Client) DownloadVerificationAgentBinary(
ctx context.Context,
arch, destPath, fallbackVersion string,
) (string, error) {
requestURL := c.buildURL(verificationAgentBinaryPath) + "?" + url.Values{"arch": {arch}}.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return "", fmt.Errorf("create agent download request: %w", err)
}
c.setStreamHeaders(req)
resp, err := c.streamHTTP.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("server returned %d for verification agent download", resp.StatusCode)
}
file, err := os.Create(destPath)
if err != nil {
return "", err
}
defer func() { _ = file.Close() }()
if _, err := io.Copy(file, resp.Body); err != nil {
return "", err
}
downloadedVersion := strings.TrimSpace(resp.Header.Get(agentVersionHeader))
if downloadedVersion == "" {
return fallbackVersion, nil
}
return downloadedVersion, nil
}
func (c *Client) Heartbeat(
ctx context.Context,
request HeartbeatRequest,
) (*HeartbeatResponse, error) {
var response HeartbeatResponse
httpResp, err := c.json.R().
SetContext(ctx).
SetBody(request).
SetResult(&response).
Post(c.buildURL(fmt.Sprintf(heartbeatPathFmt, c.agentID)))
if err != nil {
return nil, err
}
if err := c.checkResponse(httpResp, "heartbeat"); err != nil {
return nil, err
}
return &response, nil
}
// ClaimVerification performs a single claim attempt. 200 returns the assignment;
// 204 returns (nil, nil) meaning nothing fits; any other status is an error.
// The runner loop owns retry/backoff — this never loops.
func (c *Client) ClaimVerification(
ctx context.Context,
capacity AgentCapacity,
) (*JobAssignment, error) {
resp, err := c.noRetry.R().
SetContext(ctx).
SetBody(ClaimRequest{Capacity: capacity}).
Post(c.buildURL(fmt.Sprintf(claimPathFmt, c.agentID)))
if err != nil {
return nil, err
}
if resp.StatusCode() == http.StatusNoContent {
return nil, nil
}
if resp.StatusCode() != http.StatusOK {
return nil, &ResponseError{Op: "claim", StatusCode: resp.StatusCode(), Body: resp.String()}
}
var assignment JobAssignment
if err := json.Unmarshal(resp.Body(), &assignment); err != nil {
return nil, fmt.Errorf("decode job assignment: %w", err)
}
return &assignment, nil
}
// DownloadBackup opens the plaintext backup stream. On 200 it returns the
// response body for the caller to stream into the container. A non-2xx status
// is returned as *ResponseError so the caller can tell 410 (drop, no report)
// from other 4xx (agent-setup failure) from 5xx (retryable). A transport error
// is returned as-is (retryable).
func (c *Client) DownloadBackup(
ctx context.Context,
verificationID uuid.UUID,
) (io.ReadCloser, error) {
requestURL := c.buildURL(fmt.Sprintf(backupStreamPathFmt, c.agentID, verificationID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return nil, fmt.Errorf("create backup stream request: %w", err)
}
c.setStreamHeaders(req)
resp, err := c.streamHTTP.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
_ = resp.Body.Close()
return nil, &ResponseError{
Op: "backup stream",
StatusCode: resp.StatusCode,
Body: string(body),
}
}
return resp.Body, nil
}
// Report submits the terminal result with bounded retry-with-deadline. Transport
// errors and 5xx are retried with exponential backoff under reportRetryBudget; a
// 410 returns ErrReportGone immediately (zero retries); any other 4xx is a
// non-retryable error; budget exhaustion returns ErrReportBudgetExhausted so the
// run is reclaimed by the backend on the agent's next heartbeat.
func (c *Client) Report(
ctx context.Context,
verificationID uuid.UUID,
req ReportRequest,
) error {
ctx, cancel := context.WithTimeoutCause(ctx, reportRetryBudget, ErrReportBudgetExhausted)
defer cancel()
requestURL := c.buildURL(fmt.Sprintf(reportPathFmt, c.agentID, verificationID))
for attempt := 1; ; attempt++ {
resp, err := c.noRetry.R().
SetContext(ctx).
SetBody(req).
Post(requestURL)
if err == nil {
switch {
case resp.StatusCode() == http.StatusNoContent:
return nil
case resp.StatusCode() == http.StatusGone:
return ErrReportGone
case resp.StatusCode() < 500:
return &ResponseError{
Op: "report",
StatusCode: resp.StatusCode(),
Body: resp.String(),
}
}
}
select {
case <-ctx.Done():
return context.Cause(ctx)
case <-timeAfterFn(reportRetryDelay(attempt)):
}
}
}
func (c *Client) buildURL(path string) string {
return c.host + path
}
func (c *Client) checkResponse(resp *resty.Response, method string) error {
if resp.StatusCode() >= 400 {
return fmt.Errorf("%s: server returned status %d: %s", method, resp.StatusCode(), resp.String())
}
return nil
}
func (c *Client) setStreamHeaders(req *http.Request) {
if h := bearerHeader(c.token); h != "" {
req.Header.Set("Authorization", h)
}
for name, value := range noCacheHeaders {
req.Header.Set(name, value)
}
}
func bearerHeader(token string) string {
if token == "" {
return ""
}
return "Bearer " + token
}
@@ -0,0 +1,429 @@
package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"databasus-verification-agent/internal/testutil"
)
func instantAfter(t *testing.T) {
t.Helper()
original := timeAfterFn
timeAfterFn = func(time.Duration) <-chan time.Time {
ch := make(chan time.Time, 1)
ch <- time.Now()
return ch
}
t.Cleanup(func() { timeAfterFn = original })
}
func Test_ClaimVerification_WhenServer200_SendsNestedMaxRamMbAndDeserializes(t *testing.T) {
var capturedPath, capturedAuth string
var capturedBody map[string]any
verificationID := uuid.New()
backupID := uuid.New()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
capturedAuth = r.Header.Get("Authorization")
require.NoError(t, json.NewDecoder(r.Body).Decode(&capturedBody))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"verificationId": verificationID.String(),
"backupId": backupID.String(),
"backupSizeMb": 120.5,
"maxContainerDiskMb": 800.25,
"database": map[string]any{
"type": "POSTGRES_LOGICAL",
"postgresqlLogical": map[string]any{"version": "16"},
},
})
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "tok123", "agent-9", testutil.DiscardLogger())
assignment, err := client.ClaimVerification(t.Context(), AgentCapacity{
MaxCPU: 8, MaxRAMMb: 4096, MaxDiskGb: 100, MaxConcurrentJobs: 4,
})
require.NoError(t, err)
require.NotNil(t, assignment)
assert.Equal(t, "/api/v1/agent/verifications/agent-9/claim", capturedPath)
assert.Equal(t, "Bearer tok123", capturedAuth)
capacity, ok := capturedBody["capacity"].(map[string]any)
require.True(t, ok, "claim body must nest capacity under \"capacity\"")
assert.Equal(t, float64(4096), capacity["maxRamMb"])
_, hasRamGb := capacity["maxRamGb"]
assert.False(t, hasRamGb, "claim envelope must use maxRamMb, never maxRamGb")
assert.Equal(t, verificationID, assignment.VerificationID)
assert.Equal(t, backupID, assignment.BackupID)
assert.InDelta(t, 120.5, assignment.BackupSizeMb, 0.001)
assert.InDelta(t, 800.25, assignment.MaxContainerDiskMb, 0.001)
assert.Equal(t, "POSTGRES_LOGICAL", assignment.Database.Type)
require.NotNil(t, assignment.Database.PostgresqlLogical)
assert.Equal(t, "16", assignment.Database.PostgresqlLogical.Version)
}
func Test_ClaimVerification_WhenServer204_ReturnsNilNoError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "t", "a", testutil.DiscardLogger())
assignment, err := client.ClaimVerification(t.Context(), AgentCapacity{})
require.NoError(t, err)
assert.Nil(t, assignment)
}
func Test_ClaimVerification_WhenServer500_ReturnsRetryableResponseError(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "t", "a", testutil.DiscardLogger())
_, err := client.ClaimVerification(t.Context(), AgentCapacity{})
require.Error(t, err)
assert.Equal(t, int32(1), calls.Load(), "claim must not retry internally — the loop owns retry")
var respErr *ResponseError
require.True(t, errors.As(err, &respErr))
assert.True(t, respErr.Retryable())
}
func Test_DownloadBackup_WhenServer200_ReturnsBody(t *testing.T) {
payload := []byte("PGDMP fake custom-format archive")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t,
"/api/v1/agent/verifications/agent-x/"+
"22222222-2222-2222-2222-222222222222/backup-stream",
r.URL.Path)
_, _ = w.Write(payload)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "agent-x", testutil.DiscardLogger())
body, err := client.DownloadBackup(
t.Context(), uuid.MustParse("22222222-2222-2222-2222-222222222222"))
require.NoError(t, err)
t.Cleanup(func() { _ = body.Close() })
read, err := io.ReadAll(body)
require.NoError(t, err)
assert.Equal(t, payload, read)
}
func Test_DownloadBackup_WhenServer410_ReturnsGoneResponseError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusGone)
_, _ = w.Write([]byte(`{"reason":"gone"}`))
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "a", testutil.DiscardLogger())
_, err := client.DownloadBackup(t.Context(), uuid.New())
require.Error(t, err)
var respErr *ResponseError
require.True(t, errors.As(err, &respErr))
assert.True(t, respErr.IsGone())
assert.False(t, respErr.Retryable())
}
func Test_Report_WhenServer204_Succeeds(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/agent/verifications/a/33333333-3333-3333-3333-333333333333/report",
r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "a", testutil.DiscardLogger())
err := client.Report(t.Context(),
uuid.MustParse("33333333-3333-3333-3333-333333333333"),
ReportRequest{Status: VerificationStatusCompleted})
require.NoError(t, err)
}
func Test_Report_WhenServer410_ReturnsErrReportGoneWithoutRetry(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusGone)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "a", testutil.DiscardLogger())
err := client.Report(t.Context(), uuid.New(), ReportRequest{Status: VerificationStatusFailed})
require.ErrorIs(t, err, ErrReportGone)
assert.Equal(t, int32(1), calls.Load(), "410 must not be retried")
}
func Test_Report_When5xxThenRecovers_RetriesThenSucceeds(t *testing.T) {
instantAfter(t)
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if calls.Add(1) < 3 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "a", testutil.DiscardLogger())
err := client.Report(t.Context(), uuid.New(), ReportRequest{Status: VerificationStatusCompleted})
require.NoError(t, err)
assert.Equal(t, int32(3), calls.Load())
}
func Test_Report_When5xxAndBudgetExhausted_ReturnsBudgetError(t *testing.T) {
originalBudget := reportRetryBudget
reportRetryBudget = 25 * time.Millisecond
t.Cleanup(func() { reportRetryBudget = originalBudget })
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "a", testutil.DiscardLogger())
err := client.Report(t.Context(), uuid.New(), ReportRequest{Status: VerificationStatusFailed})
require.ErrorIs(t, err, ErrReportBudgetExhausted)
}
func Test_Constants_SatisfyInvariants(t *testing.T) {
// reportRetryBudget must stay a short, bounded window: once it is exhausted
// the backend reclaims the run on the agent's next heartbeat, so retrying
// for minutes would be pointless. 5m is a generous sanity ceiling.
assert.Less(t, reportRetryBudget, 5*time.Minute)
assert.Equal(t, maxBackoff, reportRetryDelay(99), "report backoff must cap at maxBackoff")
assert.Equal(t, 1*time.Second, reportRetryDelay(1))
assert.Equal(t, 2*time.Second, reportRetryDelay(2))
}
func Test_Heartbeat_WhenCalled_SendsFlatEnvelopeWithBearerAndAgentPath(t *testing.T) {
var capturedPath, capturedMethod, capturedAuth string
var capturedBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
capturedMethod = r.Method
capturedAuth = r.Header.Get("Authorization")
require.NoError(t, json.NewDecoder(r.Body).Decode(&capturedBody))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"lastSeenAt": time.Now().UTC().Format(time.RFC3339),
"abortVerificationIds": []string{},
})
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "tok123", "11111111-1111-1111-1111-111111111111", testutil.DiscardLogger())
response, err := client.Heartbeat(t.Context(), HeartbeatRequest{
MaxCPU: 8,
MaxRAMGb: 4,
MaxDiskGb: 100,
MaxConcurrentJobs: 4,
CurrentVerificationIDs: []uuid.UUID{},
})
require.NoError(t, err)
require.NotNil(t, response)
assert.Equal(t, http.MethodPost, capturedMethod)
assert.Equal(t,
"/api/v1/agent/verification/11111111-1111-1111-1111-111111111111/heartbeat",
capturedPath)
assert.Equal(t, "Bearer tok123", capturedAuth)
assert.Equal(t, float64(8), capturedBody["maxCpu"])
assert.Equal(t, float64(4), capturedBody["maxRamGb"])
assert.Equal(t, float64(100), capturedBody["maxDiskGb"])
assert.Equal(t, float64(4), capturedBody["maxConcurrentJobs"])
_, hasRamMb := capturedBody["maxRamMb"]
assert.False(t, hasRamMb, "heartbeat envelope must use maxRamGb, never maxRamMb")
require.Contains(t, capturedBody, "currentVerificationIds")
assert.Equal(t, []any{}, capturedBody["currentVerificationIds"],
"currentVerificationIds must serialize as [] not null")
}
func Test_Heartbeat_WhenServerReturns4xx_ReturnsErrorWithoutRetry(t *testing.T) {
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid agent credentials"}`))
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "bad", "agent-1", testutil.DiscardLogger())
_, err := client.Heartbeat(t.Context(), HeartbeatRequest{CurrentVerificationIDs: []uuid.UUID{}})
require.Error(t, err)
assert.Equal(t, 1, calls, "4xx must not be retried")
}
func Test_FetchServerVersion_WhenServerResponds_ReturnsVersion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/system/version", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"version": "v3.27.0"})
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "", testutil.DiscardLogger())
version, err := client.FetchServerVersion(t.Context())
require.NoError(t, err)
assert.Equal(t, "v3.27.0", version)
}
func Test_DownloadVerificationAgentBinary_WhenServerServesFile_WritesItToDisk(t *testing.T) {
payload := []byte("\x7fELF fake verification agent")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/system/verification-agent", r.URL.Path)
assert.Equal(t, "arm64", r.URL.Query().Get("arch"))
w.Header().Set(agentVersionHeader, "v3.45.0")
_, _ = w.Write(payload)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "", testutil.DiscardLogger())
destPath := filepath.Join(t.TempDir(), "agent")
downloadedVersion, err := client.DownloadVerificationAgentBinary(
t.Context(), "arm64", destPath, "v3.43.0",
)
require.NoError(t, err)
assert.Equal(t, "v3.45.0", downloadedVersion)
written, err := os.ReadFile(destPath)
require.NoError(t, err)
assert.Equal(t, payload, written)
}
func Test_DownloadVerificationAgentBinary_WhenServerOmitsVersionHeader_ReturnsFallbackVersion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("binary"))
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "", testutil.DiscardLogger())
downloadedVersion, err := client.DownloadVerificationAgentBinary(
t.Context(), "amd64", filepath.Join(t.TempDir(), "agent"), "v3.43.0",
)
require.NoError(t, err)
assert.Equal(t, "v3.43.0", downloadedVersion)
}
func Test_DownloadVerificationAgentBinary_WhenCalled_SendsNoCacheRequestHeaders(t *testing.T) {
var requestHeaders http.Header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestHeaders = r.Header.Clone()
_, _ = w.Write([]byte("binary"))
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "", testutil.DiscardLogger())
_, err := client.DownloadVerificationAgentBinary(
t.Context(), "amd64", filepath.Join(t.TempDir(), "agent"), "v1.0.0",
)
require.NoError(t, err)
assert.Equal(t, "no-cache", requestHeaders.Get("Cache-Control"))
assert.Equal(t, "no-cache", requestHeaders.Get("Pragma"))
}
func Test_FetchServerVersion_WhenCalled_SendsNoCacheRequestHeaders(t *testing.T) {
var requestHeaders http.Header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestHeaders = r.Header.Clone()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"v3.45.0"}`))
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "", testutil.DiscardLogger())
_, err := client.FetchServerVersion(t.Context())
require.NoError(t, err)
assert.Equal(t, "no-cache", requestHeaders.Get("Cache-Control"))
assert.Equal(t, "no-cache", requestHeaders.Get("Pragma"))
}
func Test_DownloadVerificationAgentBinary_WhenServer404_ReturnsError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "", testutil.DiscardLogger())
_, err := client.DownloadVerificationAgentBinary(
t.Context(), "amd64", filepath.Join(t.TempDir(), "a"), "v1.0.0",
)
require.Error(t, err)
}
@@ -0,0 +1,37 @@
package api
import "time"
const (
versionPath = "/api/v1/system/version"
verificationAgentBinaryPath = "/api/v1/system/verification-agent"
heartbeatPathFmt = "/api/v1/agent/verification/%s/heartbeat"
claimPathFmt = "/api/v1/agent/verifications/%s/claim"
backupStreamPathFmt = "/api/v1/agent/verifications/%s/%s/backup-stream"
reportPathFmt = "/api/v1/agent/verifications/%s/%s/report"
apiCallTimeout = 30 * time.Second
maxRetryAttempts = 3
retryBaseDelay = 1 * time.Second
maxBackoff = 32 * time.Second
agentVersionHeader = "X-Databasus-Version"
)
// Forces revalidation at any intermediary already holding a stale entry —
// response-side no-store only stops a proxy from caching from now on.
var noCacheHeaders = map[string]string{
"Cache-Control": "no-cache",
"Pragma": "no-cache",
}
// timeAfterFn is time.After in production; tests swap it to avoid real sleeps in
// the report retry loop. reportRetryBudget is the report retry deadline (60s in
// production); tests shrink it to exercise budget exhaustion without a 60s
// wall-clock wait.
var (
timeAfterFn = time.After
reportRetryBudget = 60 * time.Second
)
@@ -0,0 +1,88 @@
package api
import (
"fmt"
"net/http"
"time"
"github.com/google/uuid"
)
type HeartbeatRequest struct {
MaxCPU int `json:"maxCpu"`
MaxRAMGb int `json:"maxRamGb"`
MaxDiskGb int `json:"maxDiskGb"`
MaxConcurrentJobs int `json:"maxConcurrentJobs"`
CurrentVerificationIDs []uuid.UUID `json:"currentVerificationIds"`
}
type HeartbeatResponse struct {
LastSeenAt time.Time `json:"lastSeenAt"`
AbortVerificationIDs []uuid.UUID `json:"abortVerificationIds"`
}
type versionResponse struct {
Version string `json:"version"`
}
type AgentCapacity struct {
MaxCPU int `json:"maxCpu"`
MaxRAMMb int `json:"maxRamMb"`
MaxDiskGb int `json:"maxDiskGb"`
MaxConcurrentJobs int `json:"maxConcurrentJobs"`
}
type ClaimRequest struct {
Capacity AgentCapacity `json:"capacity"`
}
type AssignedPostgresql struct {
Version string `json:"version"`
}
type AssignedDatabase struct {
Type string `json:"type"`
PostgresqlLogical *AssignedPostgresql `json:"postgresqlLogical"`
}
type JobAssignment struct {
VerificationID uuid.UUID `json:"verificationId"`
BackupID uuid.UUID `json:"backupId"`
BackupSizeMb float64 `json:"backupSizeMb"`
MaxContainerDiskMb float64 `json:"maxContainerDiskMb"`
Database AssignedDatabase `json:"database"`
TimescaledbVersion string `json:"timescaledbVersion"`
}
type ReportTableStat struct {
SchemaName string `json:"schemaName"`
Name string `json:"name"`
RowCount int64 `json:"rowCount"`
}
type ReportRequest struct {
Status VerificationStatus `json:"status"`
PgRestoreExitCode *int `json:"pgRestoreExitCode"`
FailureKind *FailureType `json:"failureKind"`
RestoreDurationMs *int64 `json:"restoreDurationMs"`
VerifyDurationMs *int64 `json:"verifyDurationMs"`
DBSizeBytesAfterRestore *int64 `json:"dbSizeBytesAfterRestore"`
TableCount *int `json:"tableCount"`
SchemaCount *int `json:"schemaCount"`
TableStats []ReportTableStat `json:"tableStats"`
FailMessage *string `json:"failMessage"`
}
type ResponseError struct {
Op string
StatusCode int
Body string
}
func (e *ResponseError) Error() string {
return fmt.Sprintf("%s: server returned status %d: %s", e.Op, e.StatusCode, e.Body)
}
func (e *ResponseError) Retryable() bool { return e.StatusCode >= 500 }
func (e *ResponseError) IsGone() bool { return e.StatusCode == http.StatusGone }
@@ -0,0 +1,15 @@
package api
type VerificationStatus string
const (
VerificationStatusCompleted VerificationStatus = "COMPLETED"
VerificationStatusFailed VerificationStatus = "FAILED"
)
type FailureType string
// FailureKindDiskLimitExceeded tells the backend the restore exceeded its
// server-computed per-job disk budget — a terminal verdict, distinct from the
// retryable nil-exit-code path.
const FailureKindDiskLimitExceeded FailureType = "DISK_LIMIT_EXCEEDED"
@@ -0,0 +1,12 @@
package api
import "errors"
// ErrReportGone signals the report route returned 410 — the row is no longer
// this agent's. The caller logs at info and drops; it is never retried.
var ErrReportGone = errors.New("verification no longer owned by this agent")
// ErrReportBudgetExhausted is the cause attached when report retries exceed
// reportRetryBudget; the run is then reclaimed by the backend on the agent's
// next heartbeat.
var ErrReportBudgetExhausted = errors.New("report retry budget exhausted")
@@ -0,0 +1,60 @@
package api
import (
"context"
"fmt"
"io"
"time"
)
// IdleTimeoutReader wraps an io.Reader and cancels the associated context
// if no bytes are successfully read within the specified timeout duration.
// This detects stalled uploads where the network or source stops transmitting data.
//
// When the idle timeout fires, the reader is also closed (if it implements io.Closer)
// to unblock any goroutine blocked on the underlying Read.
type IdleTimeoutReader struct {
reader io.Reader
timeout time.Duration
cancel context.CancelCauseFunc
timer *time.Timer
}
// NewIdleTimeoutReader creates a reader that cancels the context via cancel
// if Read does not return any bytes for the given timeout duration.
func NewIdleTimeoutReader(reader io.Reader, timeout time.Duration, cancel context.CancelCauseFunc) *IdleTimeoutReader {
r := &IdleTimeoutReader{
reader: reader,
timeout: timeout,
cancel: cancel,
}
r.timer = time.AfterFunc(timeout, func() {
cancel(fmt.Errorf("upload idle timeout: no bytes transmitted for %v", timeout))
if closer, ok := reader.(io.Closer); ok {
_ = closer.Close()
}
})
return r
}
func (r *IdleTimeoutReader) Read(p []byte) (int, error) {
n, err := r.reader.Read(p)
if n > 0 {
r.timer.Reset(r.timeout)
}
if err != nil && err != io.EOF {
r.Stop()
}
return n, err
}
// Stop cancels the idle timer. Must be called when the reader is no longer needed.
func (r *IdleTimeoutReader) Stop() {
r.timer.Stop()
}
@@ -0,0 +1,112 @@
package api
import (
"context"
"fmt"
"io"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_ReadThroughIdleTimeoutReader_WhenBytesFlowContinuously_DoesNotCancelContext(t *testing.T) {
ctx, cancel := context.WithCancelCause(t.Context())
defer cancel(nil)
pr, pw := io.Pipe()
idleReader := NewIdleTimeoutReader(pr, 200*time.Millisecond, cancel)
defer idleReader.Stop()
go func() {
for range 5 {
_, _ = pw.Write([]byte("data"))
time.Sleep(50 * time.Millisecond)
}
_ = pw.Close()
}()
data, err := io.ReadAll(idleReader)
require.NoError(t, err)
assert.Equal(t, "datadatadatadatadata", string(data))
assert.NoError(t, ctx.Err(), "context should not be cancelled when bytes flow continuously")
}
func Test_ReadThroughIdleTimeoutReader_WhenNoBytesTransmitted_CancelsContext(t *testing.T) {
ctx, cancel := context.WithCancelCause(t.Context())
defer cancel(nil)
pr, _ := io.Pipe()
idleReader := NewIdleTimeoutReader(pr, 100*time.Millisecond, cancel)
defer idleReader.Stop()
time.Sleep(200 * time.Millisecond)
assert.Error(t, ctx.Err(), "context should be cancelled when no bytes are transmitted")
assert.Contains(t, context.Cause(ctx).Error(), "upload idle timeout")
}
func Test_ReadThroughIdleTimeoutReader_WhenBytesStopMidStream_CancelsContext(t *testing.T) {
ctx, cancel := context.WithCancelCause(t.Context())
defer cancel(nil)
pr, pw := io.Pipe()
idleReader := NewIdleTimeoutReader(pr, 100*time.Millisecond, cancel)
defer idleReader.Stop()
go func() {
_, _ = pw.Write([]byte("initial"))
// Stop writing — simulate stalled source
}()
buf := make([]byte, 1024)
n, _ := idleReader.Read(buf)
assert.Equal(t, "initial", string(buf[:n]))
time.Sleep(200 * time.Millisecond)
assert.Error(t, ctx.Err(), "context should be cancelled when bytes stop mid-stream")
assert.Contains(t, context.Cause(ctx).Error(), "upload idle timeout")
}
func Test_StopIdleTimeoutReader_WhenCalledBeforeTimeout_DoesNotCancelContext(t *testing.T) {
ctx, cancel := context.WithCancelCause(t.Context())
defer cancel(nil)
pr, _ := io.Pipe()
idleReader := NewIdleTimeoutReader(pr, 100*time.Millisecond, cancel)
idleReader.Stop()
time.Sleep(200 * time.Millisecond)
assert.NoError(t, ctx.Err(), "context should not be cancelled when reader is stopped before timeout")
}
func Test_ReadThroughIdleTimeoutReader_WhenReaderReturnsError_PropagatesError(t *testing.T) {
ctx, cancel := context.WithCancelCause(t.Context())
defer cancel(nil)
pr, pw := io.Pipe()
idleReader := NewIdleTimeoutReader(pr, 5*time.Second, cancel)
defer idleReader.Stop()
expectedErr := fmt.Errorf("test read error")
_ = pw.CloseWithError(expectedErr)
buf := make([]byte, 1024)
_, err := idleReader.Read(buf)
assert.ErrorIs(t, err, expectedErr)
// Timer should be stopped after error — context should not be cancelled
time.Sleep(100 * time.Millisecond)
assert.NoError(t, ctx.Err(), "context should not be cancelled after reader error stops the timer")
}
@@ -0,0 +1,108 @@
package container
import (
"context"
"fmt"
"io"
"log/slog"
"strconv"
"strings"
"time"
"databasus-verification-agent/internal/features/dbconn"
"databasus-verification-agent/internal/features/restore"
)
const (
containerTerminateTimeout = 30 * time.Second
restoreUser = "postgres"
restoreDB = "postgres"
)
// PostgresContainer exposes two distinct Postgres conns. pg_restore runs
// INSIDE the container and reaches the DB on the container's own loopback
// (GetInContainerConn). The agent-process verifier runs OUTSIDE the container
// and reaches the DB via the random 127.0.0.1 host port the container's 5432
// was published to (GetVerifierConn) — populated by Manager.Spawn after the
// container starts.
type PostgresContainer struct {
engine *dockerEngine
id string
networkID string
hostPort int
password string
log *slog.Logger
}
func (c *PostgresContainer) Exec(
ctx context.Context, cmd []string, stdin io.Reader, env []string,
) (restore.ExecResult, error) {
return c.engine.Exec(ctx, c.id, cmd, stdin, env)
}
func (c *PostgresContainer) GetInContainerConn() dbconn.Conn {
return dbconn.Conn{
Host: "127.0.0.1", Port: pgInternalPort,
User: restoreUser, Password: c.password, Database: restoreDB,
}
}
func (c *PostgresContainer) GetVerifierConn() dbconn.Conn {
return dbconn.Conn{
Host: "127.0.0.1", Port: c.hostPort,
User: restoreUser, Password: c.password, Database: restoreDB,
}
}
// GetDiskUsageBytes sums the byte footprint of the dirs the restore writes
// into: PGDATA (an anonymous volume declared by the postgres image, NOT in
// SizeRw) plus /tmp (where the agent stages the dump). Daemon-reported SizeRw
// alone misses PGDATA entirely; an in-container `du -sb` does not.
func (c *PostgresContainer) GetDiskUsageBytes(ctx context.Context) (int64, error) {
res, err := c.engine.Exec(ctx, c.id,
[]string{"du", "-sb", "/var/lib/postgresql/data", "/tmp"}, nil, nil)
if err != nil {
return 0, fmt.Errorf("exec du: %w", err)
}
if res.ExitCode != 0 {
return 0, fmt.Errorf("du exited %d: %s", res.ExitCode, strings.TrimSpace(res.Stderr))
}
var total int64
for line := range strings.SplitSeq(strings.TrimRight(res.Stdout, "\n"), "\n") {
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
n, parseErr := strconv.ParseInt(fields[0], 10, 64)
if parseErr != nil {
return 0, fmt.Errorf("parse du line %q: %w", line, parseErr)
}
total += n
}
return total, nil
}
// Terminate removes the container and its network. The caller's context is
// intentionally ignored: teardown runs during job shutdown when that context is
// usually already cancelled, so the removal deadline is rooted in a fresh
// background context. The parameter is kept to satisfy runner.JobContainer.
func (c *PostgresContainer) Terminate(context.Context) error {
ctx, cancel := context.WithTimeout(context.Background(), containerTerminateTimeout)
defer cancel()
rmErr := c.engine.RemoveContainer(ctx, c.id)
if c.networkID != "" {
if netErr := c.engine.RemoveNetwork(ctx, c.networkID); netErr != nil && rmErr == nil {
rmErr = netErr
}
}
return rmErr
}
@@ -0,0 +1,339 @@
// Package container owns the ephemeral Postgres container lifecycle. Every
// Docker-SDK call is confined to this file; the Manager builds a hardened
// SpawnSpec (unit-tested) that this file translates 1:1 to Docker.
package container
import (
"bytes"
"context"
"fmt"
"io"
"net/netip"
"strconv"
"strings"
"github.com/moby/moby/api/pkg/stdcopy"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/client"
"databasus-verification-agent/internal/features/restore"
)
const (
// LabelVerificationID / LabelAgentID tag every container and network so the
// startup purge can find and remove this agent's leftovers by agent ID
LabelVerificationID = "databasus.verification.verification_id"
LabelAgentID = "databasus.verification.agent_id"
containerPidsLimit = 512
// pgInternalPort is the in-container Postgres port; it is published to an
// ephemeral 127.0.0.1 host port for the verifier.
pgInternalPort = 5432
)
// pgPublishedPort is the parsed network.Port key reused for ExposedPorts and
// PortBindings; ParsePort is the only constructor in the new SDK.
var pgPublishedPort = mustParsePort(strconv.Itoa(pgInternalPort) + "/tcp")
func mustParsePort(s string) network.Port {
p, err := network.ParsePort(s)
if err != nil {
panic(fmt.Sprintf("parse port %q: %v", s, err))
}
return p
}
type dockerEngine struct {
cli *client.Client
}
func NewDockerEngine() (*dockerEngine, error) {
cli, err := client.New(client.FromEnv)
if err != nil {
return nil, fmt.Errorf("create docker client: %w", err)
}
return &dockerEngine{cli: cli}, nil
}
func (e *dockerEngine) Ping(ctx context.Context) error {
_, err := e.cli.Ping(ctx, client.PingOptions{})
return err
}
func (e *dockerEngine) UserNSRemapEnabled(ctx context.Context) (bool, error) {
info, err := e.cli.Info(ctx, client.InfoOptions{})
if err != nil {
return false, err
}
for _, opt := range info.Info.SecurityOptions {
if strings.Contains(opt, "name=userns") {
return true, nil
}
}
return false, nil
}
func (e *dockerEngine) EnsureImage(ctx context.Context, ref string) error {
if _, err := e.cli.ImageInspect(ctx, ref); err == nil {
return nil
}
reader, err := e.cli.ImagePull(ctx, ref, client.ImagePullOptions{})
if err != nil {
return fmt.Errorf("image pull: %w", err)
}
defer func() { _ = reader.Close() }()
if _, err := io.Copy(io.Discard, reader); err != nil {
return fmt.Errorf("drain image pull: %w", err)
}
return nil
}
func (e *dockerEngine) CreateNetwork(
ctx context.Context, name string, labels map[string]string,
) (string, error) {
// Each job gets its own user-defined bridge — that isolates jobs from each
// other. We deliberately do NOT set Internal:true: Docker silently disables
// port publishing on internal networks, and the agent-process verifier can
// only reach the restored DB via the published host port (verifier package
// doc). Outbound from PG to the host network is therefore not blocked at
// the docker layer; rely on the host firewall + the other hardening
// controls (cap-drop, no-new-privs, pids/memory, ephemeral, userns).
resp, err := e.cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
Driver: "bridge",
Labels: labels,
})
if err != nil {
return "", err
}
return resp.ID, nil
}
func (e *dockerEngine) RemoveNetwork(ctx context.Context, networkID string) error {
_, err := e.cli.NetworkRemove(ctx, networkID, client.NetworkRemoveOptions{})
return err
}
func (e *dockerEngine) CreateContainer(ctx context.Context, spec SpawnSpec) (string, error) {
securityOpt := []string{}
if spec.NoNewPrivileges {
securityOpt = append(securityOpt, "no-new-privileges")
}
capDrop := []string{}
if spec.CapDropAll {
capDrop = append(capDrop, "ALL")
}
var pidsLimit *int64
if spec.PidsLimit > 0 {
limit := spec.PidsLimit
pidsLimit = &limit
}
hostConfig := &container.HostConfig{
SecurityOpt: securityOpt,
CapDrop: capDrop,
CapAdd: spec.CapAdd,
ReadonlyRootfs: spec.ReadonlyRootfs,
NetworkMode: container.NetworkMode(spec.NetworkID),
PortBindings: network.PortMap{
pgPublishedPort: []network.PortBinding{
{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: "0"},
},
},
Resources: container.Resources{
NanoCPUs: spec.NanoCPUs,
Memory: spec.MemoryBytes,
PidsLimit: pidsLimit,
},
}
resp, err := e.cli.ContainerCreate(ctx, client.ContainerCreateOptions{
Config: &container.Config{
Image: spec.Image,
Cmd: spec.Cmd,
Env: spec.Env,
Labels: spec.Labels,
ExposedPorts: network.PortSet{pgPublishedPort: struct{}{}},
},
HostConfig: hostConfig,
Name: spec.Name,
})
if err != nil {
return "", err
}
return resp.ID, nil
}
func (e *dockerEngine) StartContainer(ctx context.Context, containerID string) error {
_, err := e.cli.ContainerStart(ctx, containerID, client.ContainerStartOptions{})
return err
}
func (e *dockerEngine) HostPort(
ctx context.Context, containerID, containerPort string,
) (int, error) {
insp, err := e.cli.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
if err != nil {
return 0, err
}
requestedPort, err := network.ParsePort(containerPort)
if err != nil {
return 0, fmt.Errorf("parse container port %q: %w", containerPort, err)
}
bindings, ok := insp.Container.NetworkSettings.Ports[requestedPort]
if !ok || len(bindings) == 0 {
return 0, fmt.Errorf("container port %s is not published", containerPort)
}
return strconv.Atoi(bindings[0].HostPort)
}
func (e *dockerEngine) InspectSecurity(
ctx context.Context, containerID string,
) (SecurityState, error) {
insp, err := e.cli.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
if err != nil {
return SecurityState{}, err
}
state := SecurityState{
ReadonlyRootfs: insp.Container.HostConfig.ReadonlyRootfs,
// Only flag mounts the AGENT requested. insp.Mounts (runtime) includes
// image-declared anonymous volumes (postgres:16 declares VOLUME
// /var/lib/postgresql/data for PGDATA) that we explicitly want; only
// host bind mounts from our spec are a hardening regression.
HasHostBinds: len(insp.Container.HostConfig.Binds) > 0 || len(insp.Container.HostConfig.Mounts) > 0,
}
for _, opt := range insp.Container.HostConfig.SecurityOpt {
if strings.Contains(opt, "no-new-privileges") {
state.NoNewPrivileges = true
}
}
for _, cap := range insp.Container.HostConfig.CapDrop {
if cap == "ALL" {
state.CapDropAll = true
}
}
return state, nil
}
func (e *dockerEngine) Exec(
ctx context.Context,
containerID string,
cmd []string,
stdin io.Reader,
env []string,
) (restore.ExecResult, error) {
created, err := e.cli.ExecCreate(ctx, containerID, client.ExecCreateOptions{
Cmd: cmd,
Env: env,
AttachStdin: stdin != nil,
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
return restore.ExecResult{}, fmt.Errorf("exec create: %w", err)
}
attached, err := e.cli.ExecAttach(ctx, created.ID, client.ExecAttachOptions{})
if err != nil {
return restore.ExecResult{}, fmt.Errorf("exec attach: %w", err)
}
defer attached.Close()
// The hijacked conn's Read/Write are not ctx-aware: a container that wedges
// while consuming stdin (or producing output) would otherwise hang Exec
// indefinitely, defeating jobCtx/heartbeat-abort. Closing it on cancellation
// unblocks the in-flight copy; the deferred Close above is then a harmless
// second close.
stopOnCancel := context.AfterFunc(ctx, func() { attached.Close() })
defer stopOnCancel()
if stdin != nil {
copyErr := make(chan error, 1)
go func() {
_, cErr := io.Copy(attached.Conn, stdin)
_ = attached.CloseWrite()
copyErr <- cErr
}()
if cErr := <-copyErr; cErr != nil {
return restore.ExecResult{}, fmt.Errorf("exec stdin copy: %w", cErr)
}
}
var stdout, stderr bytes.Buffer
if _, err := stdcopy.StdCopy(&stdout, &stderr, attached.Reader); err != nil {
return restore.ExecResult{}, fmt.Errorf("exec stream copy: %w", err)
}
inspect, err := e.cli.ExecInspect(ctx, created.ID, client.ExecInspectOptions{})
if err != nil {
return restore.ExecResult{}, fmt.Errorf("exec inspect: %w", err)
}
return restore.ExecResult{
ExitCode: inspect.ExitCode,
Stdout: stdout.String(),
Stderr: stderr.String(),
}, nil
}
func (e *dockerEngine) RemoveContainer(ctx context.Context, containerID string) error {
_, err := e.cli.ContainerRemove(ctx, containerID, client.ContainerRemoveOptions{
Force: true,
RemoveVolumes: true,
})
return err
}
func (e *dockerEngine) ListManaged(
ctx context.Context, agentID string,
) ([]ManagedContainer, error) {
listed, err := e.cli.ContainerList(ctx, client.ContainerListOptions{
All: true,
Filters: make(client.Filters).Add("label", LabelAgentID+"="+agentID),
})
if err != nil {
return nil, err
}
managed := make([]ManagedContainer, 0, len(listed.Items))
for _, s := range listed.Items {
mc := ManagedContainer{
ID: s.ID,
VerificationID: s.Labels[LabelVerificationID],
CreatedUnix: s.Created,
}
if s.NetworkSettings != nil {
for _, endpoint := range s.NetworkSettings.Networks {
if endpoint.NetworkID != "" {
mc.NetworkID = endpoint.NetworkID
break
}
}
}
managed = append(managed, mc)
}
return managed, nil
}
@@ -0,0 +1,31 @@
package container
import "context"
type fakePurgeEngine struct {
managed []ManagedContainer
removedContainer []string
removedNetwork []string
listErr error
removeContainerErr error
}
func (f *fakePurgeEngine) ListManaged(context.Context, string) ([]ManagedContainer, error) {
return f.managed, f.listErr
}
func (f *fakePurgeEngine) RemoveContainer(_ context.Context, id string) error {
if f.removeContainerErr != nil {
return f.removeContainerErr
}
f.removedContainer = append(f.removedContainer, id)
return nil
}
func (f *fakePurgeEngine) RemoveNetwork(_ context.Context, id string) error {
f.removedNetwork = append(f.removedNetwork, id)
return nil
}
@@ -0,0 +1,8 @@
package container
type ManagedContainer struct {
ID string
NetworkID string
VerificationID string
CreatedUnix int64
}
@@ -0,0 +1,302 @@
package container
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"log/slog"
"strconv"
"time"
"github.com/jackc/pgx/v5"
"databasus-verification-agent/internal/features/dbconn"
)
const (
containerReadyTimeout = 60 * time.Second
readyPollInterval = 1 * time.Second
// stockEngineImageRepo is the official Postgres image; the job's major version
// (from the backend assignment) is appended as the tag. It is the fallback
// when the configured (extension-bundling) repo can't be pulled.
stockEngineImageRepo = "postgres"
// timescaleImageRepo carries the timescaledb extension; the tag is
// "<extversion>-pg<major>" so the engine matches the dump exactly
// (pg_restore cannot cross extension versions).
timescaleImageRepo = "timescale/timescaledb"
)
// minimalCaps are the only capabilities added back after CapDrop ALL — exactly
// what the official entrypoint needs for initdb + the gosu privilege drop, not
// a blanket keep.
var minimalCaps = []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"}
// restoreTunedPostgresCmd runs the restore container's Postgres with crash
// safety traded for a smaller on-disk footprint. The container is destroyed
// after verification, so durability is irrelevant: fsync=off and
// full_page_writes=off cut WAL volume, faster checkpoints recycle pg_wal
// sooner. max_wal_senders=0 is mandatory under wal_level=minimal — Postgres
// refuses to start otherwise. The first arg stays "postgres" so the official
// image entrypoint still runs initdb.
var restoreTunedPostgresCmd = []string{
"postgres",
"-c", "fsync=off",
"-c", "full_page_writes=off",
"-c", "synchronous_commit=off",
"-c", "wal_level=minimal",
"-c", "max_wal_senders=0",
"-c", "max_wal_size=1GB",
}
type Manager struct {
engine *dockerEngine
agentID string
engineImageRepo string
log *slog.Logger
}
func NewManager(
engine *dockerEngine,
agentID string,
engineImageRepo string,
log *slog.Logger,
) *Manager {
return &Manager{
engine: engine,
agentID: agentID,
engineImageRepo: engineImageRepo,
log: log,
}
}
func (m *Manager) StartupSelfCheck(ctx context.Context) error {
if err := m.engine.Ping(ctx); err != nil {
return fmt.Errorf("docker daemon unreachable: %w", err)
}
m.log.Info("docker daemon is reachable")
remapOn, err := m.engine.UserNSRemapEnabled(ctx)
if err != nil {
m.log.Warn("could not determine user-namespace remap state", "error", err)
} else if !remapOn {
m.log.Warn(
"docker user-namespace remapping is OFF — the strongest control " +
"against a container escape is absent; enable userns-remap on the " +
"verification host")
}
return nil
}
// PurgeContainers removes every container+network this agent owns so each
// process starts from a clean slate. Call once at startup, after
// StartupSelfCheck (Docker must be reachable) and before any job is claimed.
// See purgeAgentContainers for why blanket removal is safe under one agent.
func (m *Manager) PurgeContainers(ctx context.Context) {
purgeAgentContainers(ctx, m.engine, m.agentID, m.log)
}
// Spawn's every failure is pre-pg_restore — the runner reports it with no exit
// code (retryable AgentSetupFailed), never BackupRejected.
func (m *Manager) Spawn(jobCtx context.Context, req SpawnRequest) (*PostgresContainer, error) {
image := imageForJob(req, m.engineImageRepo)
if err := m.engine.EnsureImage(jobCtx, image); err != nil {
fallbackImage, canFallBack := stockFallbackImage(req, image)
if !canFallBack {
return nil, fmt.Errorf("ensure image: %w", err)
}
// The extension-bundling image is unreachable (registry down, air-gapped,
// not yet published). Fall back to the stock Postgres image; missing
// extensions then surface as ignored pg_restore errors the runner tolerates.
m.log.Warn("bundled verification image unavailable; falling back to stock postgres image",
"bundled_image", image, "stock_image", fallbackImage, "error", err)
image = fallbackImage
if err := m.engine.EnsureImage(jobCtx, image); err != nil {
return nil, fmt.Errorf("ensure stock image: %w", err)
}
}
password, err := randomPassword()
if err != nil {
return nil, fmt.Errorf("generate password: %w", err)
}
// LabelAgentID is load-bearing for the startup purge: ListManaged filters
// solely on it, so a container spawned without this label can never be
// purged and leaks until the host is cleaned by hand.
labels := map[string]string{
LabelAgentID: m.agentID,
LabelVerificationID: req.VerificationID.String(),
}
networkID, err := m.engine.CreateNetwork(
jobCtx, "databasus-verif-"+req.VerificationID.String(), labels)
if err != nil {
return nil, fmt.Errorf("create isolated network: %w", err)
}
spec := m.buildSpec(spawnPlan{
verificationID: req.VerificationID,
image: image,
password: password,
cpuPerJob: req.CPUPerJob,
ramMbPerJob: req.RAMMbPerJob,
networkID: networkID,
labels: labels,
})
containerID, err := m.engine.CreateContainer(jobCtx, spec)
if err != nil {
_ = m.engine.RemoveNetwork(jobCtx, networkID)
return nil, fmt.Errorf("create container: %w", err)
}
c := &PostgresContainer{engine: m.engine, id: containerID, networkID: networkID, password: password, log: m.log}
if err := m.engine.StartContainer(jobCtx, containerID); err != nil {
_ = c.Terminate(jobCtx)
return nil, fmt.Errorf("start container: %w", err)
}
hostPort, err := m.engine.HostPort(jobCtx, containerID, strconv.Itoa(pgInternalPort)+"/tcp")
if err != nil {
_ = c.Terminate(jobCtx)
return nil, fmt.Errorf("resolve published port: %w", err)
}
c.hostPort = hostPort
if err := waitForReady(jobCtx, c.GetVerifierConn()); err != nil {
_ = c.Terminate(jobCtx)
return nil, fmt.Errorf("container never became ready: %w", err)
}
if err := m.assertSecurity(jobCtx, containerID); err != nil {
_ = c.Terminate(jobCtx)
return nil, err
}
return c, nil
}
func (m *Manager) buildSpec(plan spawnPlan) SpawnSpec {
return SpawnSpec{
Name: "databasus-verif-" + plan.verificationID.String(),
Image: plan.image,
Cmd: restoreTunedPostgresCmd,
Env: []string{"POSTGRES_PASSWORD=" + plan.password},
Labels: plan.labels,
NanoCPUs: int64(plan.cpuPerJob) * 1_000_000_000,
MemoryBytes: int64(plan.ramMbPerJob) * 1024 * 1024,
PidsLimit: containerPidsLimit,
NetworkID: plan.networkID,
NoNewPrivileges: true,
CapDropAll: true,
CapAdd: minimalCaps,
// rootfs is writable because the official image needs a writable PGDATA;
// the per-job disk budget is enforced by the agent's disk watcher, not a
// Docker cap. The dominant controls (cap-drop, no-new-privs, pids,
// memory, userns, ephemeral lifecycle) remain in force. The job network
// is a per-job user-defined bridge (job isolation) but NOT --internal:
// see CreateNetwork in dockerengine.go for why.
ReadonlyRootfs: false,
}
}
func (m *Manager) assertSecurity(ctx context.Context, containerID string) error {
state, err := m.engine.InspectSecurity(ctx, containerID)
if err != nil {
return fmt.Errorf("inspect security state: %w", err)
}
switch {
case !state.NoNewPrivileges:
return fmt.Errorf("hardening regression: no-new-privileges not applied")
case !state.CapDropAll:
return fmt.Errorf("hardening regression: CapDrop ALL not applied")
case state.HasHostBinds:
return fmt.Errorf("hardening regression: container has host bind mounts")
}
return nil
}
func waitForReady(ctx context.Context, conn dbconn.Conn) error {
readyCtx, cancel := context.WithTimeout(ctx, containerReadyTimeout)
defer cancel()
ticker := time.NewTicker(readyPollInterval)
defer ticker.Stop()
for {
if pingOnce(readyCtx, conn) {
return nil
}
select {
case <-readyCtx.Done():
return readyCtx.Err()
case <-ticker.C:
}
}
}
func pingOnce(ctx context.Context, conn dbconn.Conn) bool {
pingCtx, cancel := context.WithTimeout(ctx, readyPollInterval)
defer cancel()
pgConn, err := pgx.Connect(pingCtx, conn.DSN())
if err != nil {
return false
}
defer func() { _ = pgConn.Close(pingCtx) }()
return pgConn.Ping(pingCtx) == nil
}
func imageForJob(req SpawnRequest, engineImageRepo string) string {
if req.TimescaledbVersion != "" {
return fmt.Sprintf("%s:%s-pg%s", timescaleImageRepo, req.TimescaledbVersion, req.PgMajor)
}
return imageForMajor(req.PgMajor, engineImageRepo)
}
func imageForMajor(pgMajor, engineImageRepo string) string {
return engineImageRepo + ":" + pgMajor
}
// stockFallbackImage decides whether a failed pull of the configured image may
// retry against the official Postgres image. Timescale jobs need their exact
// version-matched image (pg_restore cannot cross extension versions), so they
// never fall back; nor does a job already targeting the stock repo.
func stockFallbackImage(req SpawnRequest, attemptedImage string) (string, bool) {
if req.TimescaledbVersion != "" {
return "", false
}
stockImage := imageForMajor(req.PgMajor, stockEngineImageRepo)
if stockImage == attemptedImage {
return "", false
}
return stockImage, true
}
func randomPassword() (string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
@@ -0,0 +1,129 @@
package container
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"databasus-verification-agent/internal/testutil"
)
func Test_BuildSpec_AppliesHardeningControls(t *testing.T) {
// nil engine: buildSpec is pure and never calls the engine.
containerManager := NewManager(nil, "agent-1", "databasus/verification-postgres", testutil.DiscardLogger())
labels := map[string]string{LabelAgentID: "agent-1"}
spec := containerManager.buildSpec(spawnPlan{
verificationID: uuid.New(),
image: "postgres@sha256:x",
password: "pw",
cpuPerJob: 2,
ramMbPerJob: 1024,
networkID: "net-id",
labels: labels,
})
assert.True(t, spec.NoNewPrivileges)
assert.True(t, spec.CapDropAll)
assert.Equal(t, minimalCaps, spec.CapAdd)
assert.EqualValues(t, containerPidsLimit, spec.PidsLimit)
assert.Equal(t, "net-id", spec.NetworkID)
assert.Equal(t, int64(2)*1_000_000_000, spec.NanoCPUs)
assert.Equal(t, int64(1024)*1024*1024, spec.MemoryBytes)
assert.Equal(t, []string{"POSTGRES_PASSWORD=pw"}, spec.Env)
}
func Test_BuildSpec_SetsRestoreTunedPostgresCmd(t *testing.T) {
containerManager := NewManager(nil, "agent-1", "databasus/verification-postgres", testutil.DiscardLogger())
spec := containerManager.buildSpec(spawnPlan{
verificationID: uuid.New(),
image: "postgres@sha256:x",
password: "pw",
cpuPerJob: 2,
ramMbPerJob: 1024,
networkID: "net-id",
labels: map[string]string{LabelAgentID: "agent-1"},
})
assert.Equal(t, restoreTunedPostgresCmd, spec.Cmd)
assert.Equal(t, "postgres", spec.Cmd[0])
}
func Test_GetInContainerConn_UsesInternalPort(t *testing.T) {
c := &PostgresContainer{password: "pw"}
conn := c.GetInContainerConn()
assert.Equal(t, "127.0.0.1", conn.Host)
assert.Equal(t, pgInternalPort, conn.Port)
assert.Equal(t, restoreUser, conn.User)
assert.Equal(t, restoreDB, conn.Database)
assert.Equal(t, "pw", conn.Password)
}
func Test_GetVerifierConn_WhenSpawned_UsesResolvedHostPort(t *testing.T) {
c := &PostgresContainer{password: "pw", hostPort: 54321}
conn := c.GetVerifierConn()
assert.Equal(t, "127.0.0.1", conn.Host)
assert.Equal(t, 54321, conn.Port)
assert.Equal(t, restoreUser, conn.User)
assert.Equal(t, restoreDB, conn.Database)
assert.Equal(t, "pw", conn.Password)
}
func Test_ImageForMajor_AppendsMajorTagToConfiguredRepo(t *testing.T) {
cases := []struct {
major string
expected string
}{
{"12", "databasus/verification-postgres:12"},
{"16", "databasus/verification-postgres:16"},
{"18", "databasus/verification-postgres:18"},
}
for _, tc := range cases {
t.Run(tc.major, func(t *testing.T) {
assert.Equal(t, tc.expected, imageForMajor(tc.major, "databasus/verification-postgres"))
})
}
}
func Test_ImageForJob_WhenTimescaleVersionSet_ReturnsVersionMatchedTimescaleImage(t *testing.T) {
image := imageForJob(
SpawnRequest{PgMajor: "17", TimescaledbVersion: "2.17.0"}, "databasus/verification-postgres")
assert.Equal(t, "timescale/timescaledb:2.17.0-pg17", image,
"timescale jobs ignore the engine repo and use the version-matched timescale image")
}
func Test_ImageForJob_WhenNoTimescaleVersion_UsesConfiguredEngineRepo(t *testing.T) {
image := imageForJob(SpawnRequest{PgMajor: "17"}, "databasus/verification-postgres")
assert.Equal(t, "databasus/verification-postgres:17", image)
}
func Test_StockFallbackImage_WhenBundledEngineRepo_FallsBackToStockPostgres(t *testing.T) {
fallbackImage, canFallBack := stockFallbackImage(
SpawnRequest{PgMajor: "16"}, "databasus/verification-postgres:16")
assert.True(t, canFallBack)
assert.Equal(t, "postgres:16", fallbackImage)
}
func Test_StockFallbackImage_WhenAlreadyStockRepo_DoesNotFallBack(t *testing.T) {
_, canFallBack := stockFallbackImage(SpawnRequest{PgMajor: "16"}, "postgres:16")
assert.False(t, canFallBack, "a job already on the stock image has nothing to fall back to")
}
func Test_StockFallbackImage_WhenTimescaleJob_DoesNotFallBack(t *testing.T) {
_, canFallBack := stockFallbackImage(
SpawnRequest{PgMajor: "16", TimescaledbVersion: "2.17.0"}, "timescale/timescaledb:2.17.0-pg16")
assert.False(t, canFallBack,
"timescale needs its exact version-matched image; stock postgres cannot restore it")
}
@@ -0,0 +1,58 @@
package container
import (
"context"
"fmt"
"log/slog"
)
// purgeEngine is the Docker seam for the startup purge — the same three methods
// the unit tests fake, rather than the whole Docker surface.
type purgeEngine interface {
ListManaged(ctx context.Context, agentID string) ([]ManagedContainer, error)
RemoveContainer(ctx context.Context, containerID string) error
RemoveNetwork(ctx context.Context, networkID string) error
}
// purgeAgentContainers runs once at startup, with no age grace and no
// active-set check, so each agent process begins from a clean slate
// ("always fresh").
//
// Best-effort by design: a per-item failure is logged and skipped, and a
// failed listing is logged and ignored, never blocking startup. Container and
// network names are per-UUID (databasus-verif-<uuid>), so a leftover never
// collides with a fresh run; degraded (a few stale containers until the next
// restart) beats refusing to start on a transient Docker hiccup.
func purgeAgentContainers(ctx context.Context, engine purgeEngine, agentID string, log *slog.Logger) {
managed, err := engine.ListManaged(ctx, agentID)
if err != nil {
log.Warn("startup purge could not list managed containers", "error", err)
return
}
if len(managed) == 0 {
return
}
removed := 0
for _, mc := range managed {
if err := engine.RemoveContainer(ctx, mc.ID); err != nil {
log.Warn("startup purge failed to remove container",
"verification_id", mc.VerificationID, "error", err)
continue
}
if mc.NetworkID != "" {
if err := engine.RemoveNetwork(ctx, mc.NetworkID); err != nil {
log.Warn("startup purge failed to remove network",
"verification_id", mc.VerificationID, "error", err)
}
}
removed++
}
log.Info(fmt.Sprintf("startup purge removed %d stale container(s)", removed))
}
@@ -0,0 +1,74 @@
package container
import (
"errors"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"databasus-verification-agent/internal/testutil"
)
func Test_PurgeAgentContainers_RemovesEveryContainerAndNetwork(t *testing.T) {
engine := &fakePurgeEngine{
managed: []ManagedContainer{
{ID: "c1", NetworkID: "n1", VerificationID: uuid.New().String()},
{ID: "c2", NetworkID: "n2", VerificationID: uuid.New().String()},
},
}
purgeAgentContainers(t.Context(), engine, "agent-1", testutil.DiscardLogger())
assert.ElementsMatch(t, []string{"c1", "c2"}, engine.removedContainer)
assert.ElementsMatch(t, []string{"n1", "n2"}, engine.removedNetwork)
}
func Test_PurgeAgentContainers_WhenNetworkIDEmpty_RemovesContainerOnly(t *testing.T) {
engine := &fakePurgeEngine{
managed: []ManagedContainer{{ID: "c1", VerificationID: uuid.New().String()}},
}
purgeAgentContainers(t.Context(), engine, "agent-1", testutil.DiscardLogger())
assert.Equal(t, []string{"c1"}, engine.removedContainer)
assert.Empty(t, engine.removedNetwork)
}
func Test_PurgeAgentContainers_WhenListFails_RemovesNothing(t *testing.T) {
engine := &fakePurgeEngine{
managed: []ManagedContainer{{ID: "c1", NetworkID: "n1"}},
listErr: errors.New("docker unreachable"),
}
purgeAgentContainers(t.Context(), engine, "agent-1", testutil.DiscardLogger())
assert.Empty(t, engine.removedContainer)
assert.Empty(t, engine.removedNetwork)
}
func Test_PurgeAgentContainers_WhenListEmpty_DoesNothing(t *testing.T) {
engine := &fakePurgeEngine{}
purgeAgentContainers(t.Context(), engine, "agent-1", testutil.DiscardLogger())
assert.Empty(t, engine.removedContainer)
assert.Empty(t, engine.removedNetwork)
}
func Test_PurgeAgentContainers_WhenContainerRemovalFails_SkipsNetworkAndContinues(t *testing.T) {
engine := &fakePurgeEngine{
managed: []ManagedContainer{
{ID: "c1", NetworkID: "n1", VerificationID: uuid.New().String()},
{ID: "c2", NetworkID: "n2", VerificationID: uuid.New().String()},
},
removeContainerErr: errors.New("container in use"),
}
assert.NotPanics(t, func() {
purgeAgentContainers(t.Context(), engine, "agent-1", testutil.DiscardLogger())
})
assert.Empty(t, engine.removedContainer)
assert.Empty(t, engine.removedNetwork)
}
@@ -0,0 +1,10 @@
package container
// SecurityState is read back from the live container so each job re-asserts its
// own hardening landed (a daemon glitch fails that job, never the process).
type SecurityState struct {
NoNewPrivileges bool
CapDropAll bool
ReadonlyRootfs bool
HasHostBinds bool
}
@@ -0,0 +1,44 @@
package container
import "github.com/google/uuid"
type SpawnRequest struct {
PgMajor string
CPUPerJob int
RAMMbPerJob int
VerificationID uuid.UUID
TimescaledbVersion string
}
type spawnPlan struct {
verificationID uuid.UUID
image string
password string
cpuPerJob int
ramMbPerJob int
networkID string
labels map[string]string
}
// SpawnSpec is the security-hardened container request. Every clause is a
// load-bearing control: the container is the only boundary around a restore
// that runs attacker-controlled code as the DB superuser. Constructed by the
// Manager (unit-testable) and translated 1:1 to Docker by dockerEngine.
type SpawnSpec struct {
Name string
Image string
Cmd []string
Env []string
Labels map[string]string
NanoCPUs int64
MemoryBytes int64
PidsLimit int64
NetworkID string
// The hardening invariants, explicit so a unit test asserts them and the
// engine translation cannot silently drop one.
NoNewPrivileges bool
CapDropAll bool
CapAdd []string
ReadonlyRootfs bool
}
@@ -0,0 +1,18 @@
package dbconn
import "fmt"
type Conn struct {
Host string
Port int
User string
Password string
Database string
}
func (c Conn) DSN() string {
return fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
c.Host, c.Port, c.User, c.Password, c.Database,
)
}
@@ -0,0 +1,150 @@
package heartbeat
import (
"context"
"fmt"
"log/slog"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"databasus-verification-agent/internal/config"
"databasus-verification-agent/internal/features/api"
)
const (
jobName = "verification_heartbeat"
heartbeatInterval = 30 * time.Second
)
// Heartbeater periodically reports the agent's capacity and the set of
// in-flight verification IDs (the backend reclaims any RUNNING job absent from
// that set), and acts on the abort list in the response. It is the single
// source of truth for which verifications are active and records the last
// abort set so the runner can re-check it immediately before any FAILED report
// (the abort/report-race mitigation).
type Heartbeater struct {
api *api.Client
capacity config.Capacity
hasRun atomic.Bool
mu sync.Mutex
registry map[uuid.UUID]context.CancelFunc
lastAbort map[uuid.UUID]struct{}
log *slog.Logger
}
func NewHeartbeater(apiClient *api.Client, capacity config.Capacity, log *slog.Logger) *Heartbeater {
return &Heartbeater{
api: apiClient,
capacity: capacity,
registry: make(map[uuid.UUID]context.CancelFunc),
lastAbort: make(map[uuid.UUID]struct{}),
log: log,
}
}
func (h *Heartbeater) Run(ctx context.Context) {
if h.hasRun.Swap(true) {
panic(fmt.Sprintf("%T.Run() called multiple times", h))
}
logger := h.log.With("job_id", uuid.New(), "job_name", jobName)
logger.Info("heartbeat loop started")
ticker := time.NewTicker(heartbeatInterval)
defer ticker.Stop()
if ctx.Err() == nil {
h.sendHeartbeat(ctx, logger)
}
for {
select {
case <-ctx.Done():
logger.Info("heartbeat loop stopped")
return
case <-ticker.C:
h.sendHeartbeat(ctx, logger)
}
}
}
// TrackVerification must be called before the container/network exists so the
// ID rides the heartbeat envelope and is reachable by an abort the instant it
// has any artifact.
func (h *Heartbeater) TrackVerification(id uuid.UUID, cancel context.CancelFunc) {
h.mu.Lock()
defer h.mu.Unlock()
h.registry[id] = cancel
}
func (h *Heartbeater) UntrackVerification(id uuid.UUID) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.registry, id)
}
func (h *Heartbeater) GetRunningVerificationIDs() []uuid.UUID {
h.mu.Lock()
defer h.mu.Unlock()
ids := make([]uuid.UUID, 0, len(h.registry))
for id := range h.registry {
ids = append(ids, id)
}
return ids
}
func (h *Heartbeater) IsAborted(id uuid.UUID) bool {
h.mu.Lock()
defer h.mu.Unlock()
_, aborted := h.lastAbort[id]
return aborted
}
func (h *Heartbeater) sendHeartbeat(ctx context.Context, logger *slog.Logger) {
request := api.HeartbeatRequest{
MaxCPU: h.capacity.MaxCPU,
MaxRAMGb: h.capacity.MaxRAMMb / 1024,
MaxDiskGb: h.capacity.MaxDiskGb,
MaxConcurrentJobs: h.capacity.MaxConcurrentJobs,
CurrentVerificationIDs: h.GetRunningVerificationIDs(),
}
response, err := h.api.Heartbeat(ctx, request)
if err != nil {
logger.Warn("heartbeat failed, will retry next tick", "error", err)
return
}
h.abortVerifications(response.AbortVerificationIDs, logger)
logger.Debug(fmt.Sprintf(
"heartbeat ok: last_seen_at=%s", response.LastSeenAt.UTC().Format(time.RFC3339)))
}
func (h *Heartbeater) abortVerifications(abortIDs []uuid.UUID, logger *slog.Logger) {
h.mu.Lock()
defer h.mu.Unlock()
h.lastAbort = make(map[uuid.UUID]struct{}, len(abortIDs))
for _, id := range abortIDs {
h.lastAbort[id] = struct{}{}
if cancel, registered := h.registry[id]; registered {
logger.Info(fmt.Sprintf("aborting verification %s on server request", id))
cancel()
}
}
}
@@ -0,0 +1,144 @@
package heartbeat
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"databasus-verification-agent/internal/config"
"databasus-verification-agent/internal/features/api"
"databasus-verification-agent/internal/testutil"
)
func Test_Beat_WhenNoJobsRegistered_ReportsCapacityWithRamConvertedToGbAndNoVerificationIDs(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
_ = json.NewEncoder(w).Encode(map[string]any{
"lastSeenAt": time.Now().UTC().Format(time.RFC3339),
"abortVerificationIds": []string{},
})
}))
t.Cleanup(server.Close)
client := api.NewClient(server.URL, "tok", "agent-1", testutil.DiscardLogger())
capacity := config.Capacity{
MaxCPU: 8,
MaxRAMMb: 4096,
MaxDiskGb: 100,
MaxConcurrentJobs: 4,
}
NewHeartbeater(client, capacity, testutil.DiscardLogger()).sendHeartbeat(t.Context(), testutil.DiscardLogger())
assert.Equal(t, float64(8), body["maxCpu"])
assert.Equal(t, float64(4), body["maxRamGb"], "4096 MB must be reported as 4 GB")
assert.Equal(t, float64(100), body["maxDiskGb"])
assert.Equal(t, float64(4), body["maxConcurrentJobs"])
assert.Equal(t, []any{}, body["currentVerificationIds"])
}
func Test_Beat_WhenJobRegistered_ReportsItsIDAsCurrentVerificationID(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"lastSeenAt": time.Now().UTC().Format(time.RFC3339),
"abortVerificationIds": []string{},
})
}))
t.Cleanup(server.Close)
client := api.NewClient(server.URL, "tok", "agent-1", testutil.DiscardLogger())
hb := NewHeartbeater(client, config.Capacity{}, testutil.DiscardLogger())
jobID := uuid.New()
hb.TrackVerification(jobID, func() {})
hb.sendHeartbeat(t.Context(), testutil.DiscardLogger())
ids, ok := body["currentVerificationIds"].([]any)
require.True(t, ok)
require.Len(t, ids, 1)
assert.Equal(t, jobID.String(), ids[0])
}
func Test_Beat_WhenResponseListsAbortID_CancelsMatchingJobAndRecordsAbort(t *testing.T) {
abortID := uuid.New()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"lastSeenAt": time.Now().UTC().Format(time.RFC3339),
"abortVerificationIds": []string{abortID.String()},
})
}))
t.Cleanup(server.Close)
client := api.NewClient(server.URL, "tok", "agent-1", testutil.DiscardLogger())
hb := NewHeartbeater(client, config.Capacity{}, testutil.DiscardLogger())
canceled := make(chan struct{})
hb.TrackVerification(abortID, func() { close(canceled) })
hb.sendHeartbeat(t.Context(), testutil.DiscardLogger())
select {
case <-canceled:
case <-time.After(2 * time.Second):
t.Fatal("registered job's context was not cancelled on server abort")
}
assert.True(t, hb.IsAborted(abortID),
"the abort set must be recorded for the runner's pre-FAILED re-check")
}
func Test_Heartbeater_Run_WhenCalledTwice_Panics(t *testing.T) {
client := api.NewClient("http://127.0.0.1:0", "tok", "agent-1", testutil.DiscardLogger())
heartbeater := NewHeartbeater(client, config.Capacity{}, testutil.DiscardLogger())
ctx, cancel := context.WithCancel(t.Context())
cancel()
heartbeater.Run(ctx)
assert.Panics(t, func() { heartbeater.Run(ctx) })
}
func Test_Heartbeater_Run_WhenContextCanceled_StopsPromptly(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"lastSeenAt": time.Now().UTC().Format(time.RFC3339),
"abortVerificationIds": []string{},
})
}))
t.Cleanup(server.Close)
client := api.NewClient(server.URL, "tok", "agent-1", testutil.DiscardLogger())
heartbeater := NewHeartbeater(client, config.Capacity{MaxConcurrentJobs: 1}, testutil.DiscardLogger())
ctx, cancel := context.WithCancel(t.Context())
stopped := make(chan struct{})
go func() {
heartbeater.Run(ctx)
close(stopped)
}()
cancel()
select {
case <-stopped:
case <-time.After(5 * time.Second):
t.Fatal("Run did not return after context cancellation")
}
}
@@ -0,0 +1,23 @@
package restore
import "strings"
// The runner uses this to reclassify a non-zero pg_restore exit away from
// BackupRejected: exceeding an estimate-derived bound is agent infra, not
// proof the backup is corrupt.
func IsDiskExhausted(stderrTail string) bool {
lowered := strings.ToLower(stderrTail)
for _, sig := range []string{
"no space left on device",
"could not extend",
"disk full",
"wrote only",
} {
if strings.Contains(lowered, sig) {
return true
}
}
return false
}
@@ -0,0 +1,14 @@
package restore
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_IsDiskExhausted_DetectsENOSPCSignatures(t *testing.T) {
assert.True(t, IsDiskExhausted("pg_restore: error: could not write to output file: No space left on device"))
assert.True(t, IsDiskExhausted("ERROR: could not extend file"))
assert.False(t, IsDiskExhausted("pg_restore: error: relation already exists"))
assert.False(t, IsDiskExhausted(""))
}
@@ -0,0 +1,4 @@
// Package restore drives pg_restore inside the job's ephemeral container. It
// never imports the Docker SDK: it works through the ExecRunner interface so
// the runner's exit-code contract is unit-testable with a fake.
package restore
@@ -0,0 +1,16 @@
package restore
// A non-zero ExitCode is not a Go error here — only failing to create, start,
// or attach the exec is.
type ExecResult struct {
ExitCode int
Stdout string
Stderr string
}
// Result is populated even on the error path (see ErrRestoreFailed).
type Result struct {
PgRestoreExitCode int
DurationMs int64
StderrTail string
}
@@ -0,0 +1,9 @@
package restore
import "errors"
// ErrRestoreFailed is the contract boundary the runner keys on: a non-zero
// pg_restore exit is terminal "backup rejected" (modulo the disk-ceiling
// probe), whereas any other RunPgRestore error is an exec-infrastructure
// failure with no usable exit code — a retryable agent-setup failure.
var ErrRestoreFailed = errors.New("pg_restore exited non-zero")
@@ -0,0 +1,10 @@
package restore
import (
"context"
"io"
)
type ExecRunner interface {
Exec(ctx context.Context, cmd []string, stdin io.Reader, env []string) (ExecResult, error)
}
@@ -0,0 +1,112 @@
package restore
import (
"regexp"
"slices"
"strconv"
"strings"
)
const ignoredErrorsMarker = "errors ignored on restore:"
var extensionNamePattern = regexp.MustCompile(`extension "([^"]+)"`)
// IsMissingExtensionOnly reports whether a non-zero pg_restore exit is explained
// solely by extensions absent from this environment — i.e. the data itself
// restored. pg_restore runs without --exit-on-error, so it tails "errors ignored
// on restore: N"; this returns true only when every visible item error is
// extension-related and their count equals N. The count guard matters because
// StderrTail is capped at 8192 bytes: a truncated tail could hide a non-extension
// error, so when N can't be fully accounted for we refuse to tolerate.
func IsMissingExtensionOnly(stderrTail string) bool {
ignoredCount, hasMarker := parseIgnoredErrorCount(stderrTail)
if !hasMarker || ignoredCount < 1 {
return false
}
itemErrors := queryErrorLines(stderrTail)
if len(itemErrors) != ignoredCount {
return false
}
for _, line := range itemErrors {
if !isExtensionErrorLine(line) {
return false
}
}
return true
}
// ExtractUnavailableExtensions returns the de-duplicated, sorted names of the
// extensions pg_restore could not create, for an operator-facing log line.
func ExtractUnavailableExtensions(stderrTail string) []string {
seen := map[string]struct{}{}
var names []string
for _, line := range queryErrorLines(stderrTail) {
if !isExtensionErrorLine(line) {
continue
}
for _, match := range extensionNamePattern.FindAllStringSubmatch(line, -1) {
name := match[1]
if _, isDup := seen[name]; isDup {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
}
slices.Sort(names)
return names
}
func parseIgnoredErrorCount(stderrTail string) (int, bool) {
idx := strings.LastIndex(strings.ToLower(stderrTail), ignoredErrorsMarker)
if idx < 0 {
return 0, false
}
fields := strings.Fields(stderrTail[idx+len(ignoredErrorsMarker):])
if len(fields) == 0 {
return 0, false
}
count, err := strconv.Atoi(fields[0])
if err != nil {
return 0, false
}
return count, true
}
func queryErrorLines(stderrTail string) []string {
var lines []string
for line := range strings.SplitSeq(stderrTail, "\n") {
if strings.Contains(strings.ToLower(line), "could not execute query: error:") {
lines = append(lines, line)
}
}
return lines
}
// isExtensionErrorLine matches the phrasings a missing extension produces — the
// failed CREATE EXTENSION and its cascading COMMENT ON EXTENSION. The "extension"
// guard keeps unrelated cascades (`type "..." does not exist`) out.
func isExtensionErrorLine(line string) bool {
lowered := strings.ToLower(line)
if !strings.Contains(lowered, "extension") {
return false
}
return strings.Contains(lowered, "is not available") ||
strings.Contains(lowered, "could not open extension control file") ||
strings.Contains(lowered, "does not exist")
}

Some files were not shown because too many files have changed in this diff Show More