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
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:
@@ -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
@@ -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."
|
||||
}'
|
||||
@@ -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\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
Executable
+32
@@ -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
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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. -->
|
||||
@@ -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).
|
||||
@@ -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
@@ -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 }}"
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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, 2–3 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
@@ -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 []
|
||||
@@ -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.
|
||||
@@ -0,0 +1,22 @@
|
||||
Copyright © 2025–2026 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.
|
||||
@@ -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 -->
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.mysql.com/)
|
||||
[](https://mariadb.org/)
|
||||
[](https://www.mongodb.com/)
|
||||
<br />
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/databasus/databasus)
|
||||
[](https://github.com/databasus/databasus)
|
||||
[](https://github.com/databasus/databasus)
|
||||
[](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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`databasus/databasus`
|
||||
- 原始仓库:https://github.com/databasus/databasus
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -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
|
||||
@@ -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 5–10 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 12–16 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 5–15% production setups.
|
||||
@@ -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 20–60s 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 10–11 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 × 20–60s 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.5–4 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`
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
DATABASUS_URL=
|
||||
AGENT_ID=
|
||||
AGENT_TOKEN=
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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}
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user