P5: OSS readiness — docs site, contributor onboarding, e2e harness

P5-01 — Documentation site under docs/book/ rendered with mdBook
(downloaded via Makefile, same static-binary pattern as Tailwind).
Structured chapters: getting started, concepts, operations,
security, reference. `make docs` / `make docs-watch`. Generated
output gitignored.

P5-02 — CONTRIBUTING.md rewritten from placeholder to a full
guide. CODE_OF_CONDUCT.md adapted from Contributor Covenant for a
single-maintainer project. .gitea/issue_template/{bug,feature}.md
and PULL_REQUEST_TEMPLATE.md.

P5-04 — Six README screenshots captured live from a fresh server
bootstrap (login, empty dashboard, add-host, alerts, settings,
audit log). README rewritten to centre the screenshot grid and
link out to the docs site.

P5-05 — SECURITY.md with disclosure policy (3-day ack, 30-day
default window), scope in/out, threat-model summary, operator
hardening checklist. Mirrored as a docs-site chapter.

P5-06 — End-to-end test harness. e2e/compose.e2e.yml brings up
server + sibling Linux agent (alpine + restic) + restic/rest-server.
Agent uses announce-and-approve so Playwright can drive the full
operator flow: bootstrap → login → accept pending → backup →
verify terminal status. Second spec scrapes /metrics to assert
the P6-04 endpoint surface. .gitea/workflows/e2e.yml runs on every
PR; local how-to in docs/e2e.md.
This commit is contained in:
2026-05-07 23:56:02 +01:00
parent a252b25854
commit 89537d417a
47 changed files with 2818 additions and 61 deletions
+42
View File
@@ -0,0 +1,42 @@
# Build a Linux container that runs the restic-manager agent against a
# sibling rest-server in the e2e compose stack. Used only by tests
# (e2e/compose.e2e.yml + .gitea/workflows/e2e.yml).
#
# Two stages:
# 1. golang:alpine to build the agent binary.
# 2. alpine:3.20 with the `restic` package + the built binary.
#
# Pinning by digest is intentional for CI reproducibility.
FROM golang:1.25-alpine AS build
WORKDIR /src
ENV CGO_ENABLED=0 \
GOFLAGS="-trimpath"
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
ARG VERSION=e2e
RUN go build -ldflags="-s -w -X gitea.dcglab.co.uk/steve/restic-manager/internal/version.Version=${VERSION}" \
-o /out/restic-manager-agent ./cmd/agent
FROM alpine:3.20
RUN apk add --no-cache restic ca-certificates curl
COPY --from=build /out/restic-manager-agent /usr/local/bin/restic-manager-agent
# Agents normally run as root because backup paths often need it. The
# e2e fixture only backs up paths under /data which we own, so this
# container would tolerate a non-root user — but staying root keeps
# parity with the production install.
USER root
# The agent needs a writable directory for its config + secrets store.
RUN mkdir -p /etc/restic-manager /var/lib/restic-manager-agent
ENV RM_AGENT_CONFIG=/etc/restic-manager/agent.yaml
# The compose entrypoint sets the announce URL via env.
COPY e2e/agent-entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# Entrypoint for the e2e agent container.
#
# Three states:
# 1. Already enrolled (agent.yaml has a bearer): run the agent.
# 2. Token supplied via $RM_ENROL_TOKEN: enrol then run.
# 3. Otherwise: announce against $RM_SERVER and wait for an admin to
# accept us. The announce flow blocks until accepted, then drops
# straight into the normal run loop, so this is the test-friendly
# path.
set -eu
CFG="${RM_AGENT_CONFIG:-/etc/restic-manager/agent.yaml}"
SERVER="${RM_SERVER:?set RM_SERVER}"
if [ -f "$CFG" ] && grep -q '^agent_token:' "$CFG"; then
exec restic-manager-agent -config "$CFG"
fi
if [ -n "${RM_ENROL_TOKEN:-}" ]; then
exec restic-manager-agent -config "$CFG" \
-enroll-server "$SERVER" \
-enroll-token "$RM_ENROL_TOKEN"
fi
# Announce-and-approve: blocks until an admin accepts, then runs.
exec restic-manager-agent -config "$CFG" -enroll-server "$SERVER"
+87
View File
@@ -0,0 +1,87 @@
# End-to-end test stack — used by .gitea/workflows/e2e.yml and by
# operators who want to run the Playwright suite locally.
#
# Three services:
# * server — restic-manager built from the working tree
# * agent — restic-manager agent built from the working tree
# (announces; Playwright accepts it during the test)
# * rest-server — the actual restic backend, sibling of the agent
#
# Run from the repo root:
# docker compose -f e2e/compose.e2e.yml up --build --abort-on-container-exit
services:
rest-server:
image: restic/rest-server:0.13.0
environment:
DATA_DIR: /data
OPTIONS: "--no-auth"
volumes:
- rest-data:/data
networks: [rmnet]
server:
build:
context: ..
dockerfile: deploy/Dockerfile.server
args:
VERSION: e2e
environment:
RM_LISTEN: ":8080"
RM_DATA_DIR: "/data"
RM_BASE_URL: "http://server:8080"
RM_COOKIE_SECURE: "false"
# Bind the metrics endpoint loose for the test, so one of the
# Playwright assertions can exercise it.
RM_METRICS_TRUSTED_CIDR: "0.0.0.0/0"
volumes:
- server-data:/data
ports:
- "127.0.0.1:8080:8080"
healthcheck:
test: ["CMD", "/usr/local/bin/restic-manager-server", "--version"]
interval: 2s
timeout: 2s
retries: 30
networks: [rmnet]
agent:
build:
context: ..
dockerfile: e2e/Dockerfile.agent
args:
VERSION: e2e
environment:
RM_SERVER: "http://server:8080"
depends_on:
- server
volumes:
# Source paths the agent backs up. Compose pre-populates this
# with a few files so the snapshot list isn't empty.
- source-data:/source
- agent-config:/etc/restic-manager
- agent-state:/var/lib/restic-manager-agent
networks: [rmnet]
# One-shot init container that drops a couple of files into the
# source volume so backups have something to snapshot.
source-fixture:
image: alpine:3.20
command: >
sh -c 'mkdir -p /source && echo "hello world" > /source/hello.txt &&
echo "another file" > /source/two.txt && sleep 0.2'
volumes:
- source-data:/source
networks: [rmnet]
restart: "no"
volumes:
server-data:
rest-data:
source-data:
agent-config:
agent-state:
networks:
rmnet:
driver: bridge
+14
View File
@@ -0,0 +1,14 @@
{
"name": "restic-manager-e2e",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:debug": "PWDEBUG=1 playwright test"
},
"devDependencies": {
"@playwright/test": "^1.50.0"
}
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig, devices } from '@playwright/test';
// Single-target Chromium config: the e2e suite is narrow (smoke
// the production-shaped flow against the docker-compose stack).
// Cross-browser matrix doesn't add signal — what we're verifying is
// the server's HTML and the agent's WebSocket handshake, neither of
// which depends on browser engine.
const baseURL = process.env.RM_BASE_URL ?? 'http://127.0.0.1:8080';
export default defineConfig({
testDir: './tests',
timeout: 60_000,
expect: { timeout: 10_000 },
fullyParallel: false,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
+114
View File
@@ -0,0 +1,114 @@
// Helpers used by every test. The shape favours the JSON API for
// reads + accept/dispatch (deterministic, easy to assert) and the
// browser for human-facing surfaces (login form, dashboard render).
import { APIRequestContext, expect, Page } from '@playwright/test';
export const baseURL = process.env.RM_BASE_URL ?? 'http://127.0.0.1:8080';
export interface HostJSON {
id: string;
name: string;
status: string;
last_backup_status?: string;
}
export async function readBootstrapToken(): Promise<string> {
const tok = process.env.RM_BOOTSTRAP_TOKEN;
if (!tok) {
throw new Error('RM_BOOTSTRAP_TOKEN not set — the harness scrapes it from server logs');
}
return tok;
}
export async function bootstrapAdmin(
request: APIRequestContext,
{
username = 'admin',
password = 'e2e-test-password-1234',
}: { username?: string; password?: string } = {},
): Promise<{ username: string; password: string }> {
const token = await readBootstrapToken();
const res = await request.post(`${baseURL}/api/bootstrap`, {
data: { token, username, password },
});
if (!res.ok() && res.status() !== 409 /* already bootstrapped */) {
throw new Error(`bootstrap: ${res.status()} ${await res.text()}`);
}
return { username, password };
}
export async function loginViaUI(page: Page, username: string, password: string): Promise<void> {
await page.goto(`${baseURL}/login`);
await page.locator('#login-username').fill(username);
await page.locator('#login-password').fill(password);
await Promise.all([
page.waitForURL(new RegExp(`^${baseURL}/?$`)),
page.locator('form[action="/login"] button[type="submit"]').click(),
]);
}
/**
* Polls the dashboard until a pending host card is visible, then
* extracts its pending-id from the inline accept form's action URL.
*/
export async function waitForPendingHostID(page: Page): Promise<string> {
const formLocator = page.locator('form[action^="/api/pending-hosts/"][action$="/accept"]').first();
await expect(formLocator).toBeVisible({ timeout: 60_000 });
const action = await formLocator.getAttribute('action');
if (!action) throw new Error('pending host form has no action attribute');
const m = action.match(/\/api\/pending-hosts\/([^/]+)\/accept/);
if (!m) throw new Error(`unexpected action URL: ${action}`);
return m[1];
}
export async function acceptPending(
request: APIRequestContext,
cookie: string,
pendingID: string,
repo: { url: string; username?: string; password: string },
): Promise<void> {
const res = await request.post(`${baseURL}/api/pending-hosts/${pendingID}/accept`, {
headers: { cookie, 'content-type': 'application/json' },
data: {
repo_url: repo.url,
repo_username: repo.username ?? '',
repo_password: repo.password,
},
});
if (!res.ok()) {
throw new Error(`accept: ${res.status()} ${await res.text()}`);
}
}
export async function listHosts(request: APIRequestContext, cookie: string): Promise<HostJSON[]> {
const res = await request.get(`${baseURL}/api/hosts`, { headers: { cookie } });
if (!res.ok()) throw new Error(`list hosts: ${res.status()} ${await res.text()}`);
const body = (await res.json()) as { items?: HostJSON[]; hosts?: HostJSON[] };
return body.items ?? body.hosts ?? [];
}
export async function waitForHostStatus(
request: APIRequestContext,
cookie: string,
matcher: (h: HostJSON) => boolean,
timeoutMs = 60_000,
): Promise<HostJSON> {
const deadline = Date.now() + timeoutMs;
let last: HostJSON | undefined;
while (Date.now() < deadline) {
const hosts = await listHosts(request, cookie);
const hit = hosts.find(matcher);
if (hit) return hit;
last = hosts[0];
await new Promise((r) => setTimeout(r, 1_000));
}
throw new Error(`waitForHostStatus: timeout. Last seen: ${JSON.stringify(last)}`);
}
export async function getSessionCookie(page: Page): Promise<string> {
const cookies = await page.context().cookies();
const c = cookies.find((c) => c.name === 'rm_session');
if (!c) throw new Error('rm_session cookie not set after login');
return `${c.name}=${c.value}`;
}
+80
View File
@@ -0,0 +1,80 @@
// End-to-end smoke: bootstrap → accept pending host → run backup → see succeeded.
//
// The compose stack stands up a server, a sibling rest-server, and an
// agent in announce-and-approve mode. This test drives the operator
// path through the UI (login + dashboard) and the API
// (accept + run-now + poll for terminal) — UI for the human surfaces,
// API for the deterministic ones.
import { test, expect } from '@playwright/test';
import {
baseURL,
bootstrapAdmin,
loginViaUI,
waitForPendingHostID,
acceptPending,
waitForHostStatus,
getSessionCookie,
} from './lib/server';
test.describe('smoke: enrol-via-announce → backup', () => {
test('happy path completes in under a minute', async ({ page, request }) => {
const { username, password } = await bootstrapAdmin(request);
await loginViaUI(page, username, password);
// Dashboard renders.
await expect(page.locator('main')).toContainText(/host|fleet|pending/i, { timeout: 10_000 });
// Pending host appears (the agent container has been
// announcing since startup).
const pendingID = await waitForPendingHostID(page);
const cookie = await getSessionCookie(page);
// Accept with the rest-server creds. compose's rest-server runs
// --no-auth, so any credentials work; restic still demands a
// password to encrypt the repo.
await acceptPending(request, cookie, pendingID, {
url: 'rest:http://rest-server:8000/',
password: 'e2e-repo-password',
});
// Wait for the host to come online + auto-init to land.
const onlineHost = await waitForHostStatus(
request, cookie,
(h) => h.status === 'online',
60_000,
);
expect(onlineHost.id).toBeTruthy();
// Trigger a backup via the UI form-post (HX-Redirect to /jobs/{id}).
await page.goto(`${baseURL}/hosts/${onlineHost.id}`);
await Promise.all([
page.waitForURL(/\/jobs\//),
page.locator('form[action$="/run-backup"] button[type="submit"]').first().click(),
]);
// Wait for the host's last_backup_status to flip to 'succeeded'.
// The job page itself is harder to assert on (it uses
// server-pushed updates and a reload-on-finish pattern); the
// host record is the source of truth and is what the dashboard
// surfaces.
const finishedHost = await waitForHostStatus(
request, cookie,
(h) => h.id === onlineHost.id && h.last_backup_status === 'succeeded',
120_000,
);
expect(finishedHost.last_backup_status).toBe('succeeded');
});
});
test.describe('smoke: scrape /metrics', () => {
test('metrics endpoint exposes the host gauge', async ({ request }) => {
// Compose sets RM_METRICS_TRUSTED_CIDR=0.0.0.0/0 so the
// endpoint is open to the test runner.
const res = await request.get(`${baseURL}/metrics`);
expect(res.status()).toBe(200);
const body = await res.text();
expect(body).toContain('rm_hosts_total');
expect(body).toContain('rm_build_info{');
});
});