Merge branch 'docs/backup-and-test-defaults'
This commit is contained in:
19
PROJECT.md
19
PROJECT.md
@@ -1133,16 +1133,19 @@ eventsnap/
|
|||||||
|
|
||||||
### Backup Strategy
|
### Backup Strategy
|
||||||
|
|
||||||
```bash
|
Three artefacts in three places: the database, the `media_data` volume
|
||||||
# Daily (e.g. as a separate Compose service or cron on the VPS)
|
(originals + derivatives), and the **separate** `exports_data` volume. See
|
||||||
pg_dump $DATABASE_URL | gzip > /media/backups/db_$(date +%Y-%m-%d).sql.gz
|
[README.md](README.md#backup) for the exact commands.
|
||||||
|
|
||||||
# Weekly: rsync /media volume to Hetzner Storage Box
|
Everything runs through `docker compose` / `docker run`, because `DATABASE_URL`
|
||||||
rsync -az /opt/eventsnap/media/ \
|
and the `/media` and `/exports` paths only exist inside the compose network —
|
||||||
user@u123456.your-storagebox.de:backup/eventsnap/
|
they are not host paths, and `DATABASE_URL` is never exported into an operator's
|
||||||
```
|
shell.
|
||||||
|
|
||||||
The `/media` volume contains originals, previews, thumbnails, generated exports, and DB backups — a single volume to back up.
|
Export archives are deliberately outside `MEDIA_PATH` (`EXPORT_PATH=/exports`): a
|
||||||
|
keepsake contains every photo in the event, and keeping it off the media tree is
|
||||||
|
what stops it being reachable except through the ticket-gated handler. A backup
|
||||||
|
of the media volume alone silently loses every generated keepsake.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
51
README.md
51
README.md
@@ -162,23 +162,58 @@ See [.env.example](.env.example) for the full list with descriptions and default
|
|||||||
└────────┘
|
└────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- `/api/*` and `/media/*` → Rust backend
|
- `/api/*` → Rust backend
|
||||||
- Everything else → SvelteKit frontend (`adapter-node`)
|
- Everything else → SvelteKit frontend (`adapter-node`)
|
||||||
- Named volumes: `postgres_data`, `media_data`, `caddy_data`
|
- Named volumes: `postgres_data`, `media_data`, `exports_data`, `caddy_data`
|
||||||
|
|
||||||
|
Media is **not** served as static files. Every image goes through a
|
||||||
|
visibility-checked alias (`/api/v1/upload/{id}/{preview,display,thumbnail,original}`)
|
||||||
|
so a host takedown or a ban actually revokes access to the bytes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Backup
|
## Backup
|
||||||
|
|
||||||
```bash
|
There are **three** things to back up, and they live in three different places.
|
||||||
# Database snapshot
|
`DATABASE_URL` and the container paths (`/media`, `/exports`) are meaningful only
|
||||||
pg_dump $DATABASE_URL | gzip > /media/backups/db_$(date +%Y-%m-%d).sql.gz
|
*inside* the compose network — they are not host paths, and `DATABASE_URL` is
|
||||||
|
never exported into an operator's shell — so every command below runs through
|
||||||
|
`docker compose` from the repo directory.
|
||||||
|
|
||||||
# Weekly offsite sync (Hetzner Storage Box or similar)
|
```bash
|
||||||
rsync -az /opt/eventsnap/media/ user@storagebox.example.com:backup/eventsnap/
|
# 1. Database snapshot. Runs pg_dump inside the db container (the app image has no
|
||||||
|
# postgres client), reading credentials from the compose environment.
|
||||||
|
mkdir -p ./backups
|
||||||
|
docker compose exec -T db \
|
||||||
|
sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' \
|
||||||
|
| gzip > ./backups/db_$(date +%Y-%m-%d).sql.gz
|
||||||
|
|
||||||
|
# 2. Uploaded media (originals + derivatives) out of the named volume.
|
||||||
|
# NOTE the mountpoint is /src, not /media: if the volume is ever empty, Docker
|
||||||
|
# pre-populates a fresh mount from the image's own directory, and alpine ships a
|
||||||
|
# /media containing cdrom/floppy/usb. Mounting somewhere the image has nothing
|
||||||
|
# avoids silently tarring (and polluting the volume with) those.
|
||||||
|
docker run --rm \
|
||||||
|
-v eventsnap_media_data:/src:ro -v "$PWD/backups":/backup \
|
||||||
|
alpine tar czf /backup/media_$(date +%Y-%m-%d).tar.gz -C /src .
|
||||||
|
|
||||||
|
# 3. Export archives — a SEPARATE volume (see the security note below).
|
||||||
|
docker run --rm \
|
||||||
|
-v eventsnap_exports_data:/src:ro -v "$PWD/backups":/backup \
|
||||||
|
alpine tar czf /backup/exports_$(date +%Y-%m-%d).tar.gz -C /src .
|
||||||
|
|
||||||
|
# Weekly offsite sync of the three artefacts above.
|
||||||
|
rsync -az ./backups/ user@storagebox.example.com:backup/eventsnap/
|
||||||
```
|
```
|
||||||
|
|
||||||
The `/media` volume holds originals, previews, thumbnails, exports, and DB backups — a single path to back up.
|
Volume names are prefixed with the compose project name — `eventsnap_` if you run
|
||||||
|
from a directory called `eventsnap`. Confirm yours with `docker volume ls`.
|
||||||
|
|
||||||
|
> **Exports are deliberately NOT under `/media`.** They live on their own
|
||||||
|
> `exports_data` volume (`EXPORT_PATH=/exports`) because a keepsake archive
|
||||||
|
> contains every photo in the event; keeping it outside the media tree is what
|
||||||
|
> stops it being reachable except through the ticket-gated download handler.
|
||||||
|
> Backing up only the media volume therefore loses every generated keepsake.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -42,11 +42,20 @@ services:
|
|||||||
EVENT_NAME: E2E Test Event
|
EVENT_NAME: E2E Test Event
|
||||||
APP_PORT: '3000'
|
APP_PORT: '3000'
|
||||||
MEDIA_PATH: /media
|
MEDIA_PATH: /media
|
||||||
|
# Exports MUST live outside MEDIA_PATH — see the note on the volume below and
|
||||||
|
# config.rs::validate. Omitting this left exports on the container's writable
|
||||||
|
# layer at the /exports default, so the test stack diverged from the prod layout
|
||||||
|
# it claims to mirror, and export-leak/export-video wrote real archives into
|
||||||
|
# ephemeral storage.
|
||||||
|
EXPORT_PATH: /exports
|
||||||
SESSION_EXPIRY_DAYS: '30'
|
SESSION_EXPIRY_DAYS: '30'
|
||||||
EVENTSNAP_TEST_MODE: '1' # ENABLES /admin/__truncate — never set in prod
|
EVENTSNAP_TEST_MODE: '1' # ENABLES /admin/__truncate — never set in prod
|
||||||
RUST_LOG: eventsnap_backend=info,tower_http=warn
|
RUST_LOG: eventsnap_backend=info,tower_http=warn
|
||||||
volumes:
|
volumes:
|
||||||
- media_data:/media
|
- media_data:/media
|
||||||
|
# Separate volume, exactly as in production: a keepsake archive contains every
|
||||||
|
# photo in the event, so it is kept off the media tree.
|
||||||
|
- exports_data:/exports
|
||||||
expose:
|
expose:
|
||||||
- '3000'
|
- '3000'
|
||||||
|
|
||||||
@@ -75,3 +84,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
media_data:
|
media_data:
|
||||||
|
exports_data:
|
||||||
|
|||||||
@@ -56,14 +56,21 @@ function upload(jwt: string, name: string) {
|
|||||||
/**
|
/**
|
||||||
* Pick a `quota_tolerance` that makes the per-user ceiling land on `targetBytes`.
|
* Pick a `quota_tolerance` that makes the per-user ceiling land on `targetBytes`.
|
||||||
* limit = floor(free_disk * tolerance / max(active, 1)) ⇒ tolerance = target * active / free.
|
* limit = floor(free_disk * tolerance / max(active, 1)) ⇒ tolerance = target * active / free.
|
||||||
|
*
|
||||||
|
* `staffJwt` reads the calibration inputs, `jwt` is the guest the limit is being aimed at.
|
||||||
|
* They must be different tokens: `free_disk_bytes` and `active_uploaders` are raw server
|
||||||
|
* telemetry and `/me/quota` zeroes both for non-staff (handlers/me.rs — "must never reach a
|
||||||
|
* guest"). Calibrating off the guest's own response divides by zero and yields a NaN
|
||||||
|
* tolerance, which is what silently broke this whole describe block.
|
||||||
*/
|
*/
|
||||||
async function setLimitTo(
|
async function setLimitTo(
|
||||||
api: any,
|
api: any,
|
||||||
adminToken: string,
|
adminToken: string,
|
||||||
|
staffJwt: string,
|
||||||
jwt: string,
|
jwt: string,
|
||||||
targetBytes: number
|
targetBytes: number
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const q = await quotaOf(jwt);
|
const q = await quotaOf(staffJwt);
|
||||||
expect(
|
expect(
|
||||||
q.free_disk_bytes,
|
q.free_disk_bytes,
|
||||||
'the disk must be readable, else quota fails OPEN and proves nothing'
|
'the disk must be readable, else quota fails OPEN and proves nothing'
|
||||||
@@ -72,6 +79,7 @@ async function setLimitTo(
|
|||||||
const tolerance = (targetBytes * active) / (q.free_disk_bytes as number);
|
const tolerance = (targetBytes * active) / (q.free_disk_bytes as number);
|
||||||
await api.patchConfig(adminToken, { quota_tolerance: tolerance.toExponential(12) });
|
await api.patchConfig(adminToken, { quota_tolerance: tolerance.toExponential(12) });
|
||||||
|
|
||||||
|
// Read back through the GUEST, whose ceiling is the one under test.
|
||||||
const after = await quotaOf(jwt);
|
const after = await quotaOf(jwt);
|
||||||
expect(after.enabled).toBe(true);
|
expect(after.enabled).toBe(true);
|
||||||
return after.limit_bytes as number;
|
return after.limit_bytes as number;
|
||||||
@@ -89,10 +97,11 @@ test.describe('Upload — storage quota enforcement', () => {
|
|||||||
api,
|
api,
|
||||||
adminToken,
|
adminToken,
|
||||||
guest,
|
guest,
|
||||||
|
host,
|
||||||
}) => {
|
}) => {
|
||||||
const g = await guest('QuotaOver');
|
const g = await guest('QuotaOver');
|
||||||
// Ceiling below one file: the very first upload must be refused.
|
// Ceiling below one file: the very first upload must be refused.
|
||||||
const limit = await setLimitTo(api, adminToken, g.jwt, Math.floor(SIZE / 2));
|
const limit = await setLimitTo(api, adminToken, host.jwt, g.jwt, Math.floor(SIZE / 2));
|
||||||
expect(limit).toBeLessThan(SIZE);
|
expect(limit).toBeLessThan(SIZE);
|
||||||
|
|
||||||
const res = await upload(g.jwt, 'too-big.jpg');
|
const res = await upload(g.jwt, 'too-big.jpg');
|
||||||
@@ -111,9 +120,10 @@ test.describe('Upload — storage quota enforcement', () => {
|
|||||||
api,
|
api,
|
||||||
adminToken,
|
adminToken,
|
||||||
guest,
|
guest,
|
||||||
|
host,
|
||||||
}) => {
|
}) => {
|
||||||
const g = await guest('QuotaUnder');
|
const g = await guest('QuotaUnder');
|
||||||
await setLimitTo(api, adminToken, g.jwt, SIZE * 4);
|
await setLimitTo(api, adminToken, host.jwt, g.jwt, SIZE * 4);
|
||||||
|
|
||||||
expect((await upload(g.jwt, 'fine.jpg')).status).toBe(201);
|
expect((await upload(g.jwt, 'fine.jpg')).status).toBe(201);
|
||||||
expect((await quotaOf(g.jwt)).used_bytes).toBe(SIZE);
|
expect((await quotaOf(g.jwt)).used_bytes).toBe(SIZE);
|
||||||
@@ -123,11 +133,12 @@ test.describe('Upload — storage quota enforcement', () => {
|
|||||||
api,
|
api,
|
||||||
adminToken,
|
adminToken,
|
||||||
guest,
|
guest,
|
||||||
|
host,
|
||||||
}) => {
|
}) => {
|
||||||
const g = await guest('QuotaRacer');
|
const g = await guest('QuotaRacer');
|
||||||
|
|
||||||
// Room for exactly ONE file.
|
// Room for exactly ONE file.
|
||||||
const limit = await setLimitTo(api, adminToken, g.jwt, Math.floor(SIZE * 1.5));
|
const limit = await setLimitTo(api, adminToken, host.jwt, g.jwt, Math.floor(SIZE * 1.5));
|
||||||
expect(limit).toBeGreaterThanOrEqual(SIZE);
|
expect(limit).toBeGreaterThanOrEqual(SIZE);
|
||||||
expect(limit).toBeLessThan(SIZE * 2);
|
expect(limit).toBeLessThan(SIZE * 2);
|
||||||
|
|
||||||
@@ -170,10 +181,11 @@ test.describe('Upload — storage quota enforcement', () => {
|
|||||||
api,
|
api,
|
||||||
adminToken,
|
adminToken,
|
||||||
guest,
|
guest,
|
||||||
|
host,
|
||||||
}) => {
|
}) => {
|
||||||
// Zero test hits before this — and it is the source of the "X von Y MB genutzt" widget.
|
// Zero test hits before this — and it is the source of the "X von Y MB genutzt" widget.
|
||||||
const g = await guest('QuotaWidget');
|
const g = await guest('QuotaWidget');
|
||||||
await setLimitTo(api, adminToken, g.jwt, SIZE * 10);
|
await setLimitTo(api, adminToken, host.jwt, g.jwt, SIZE * 10);
|
||||||
|
|
||||||
const before = await quotaOf(g.jwt);
|
const before = await quotaOf(g.jwt);
|
||||||
expect(before.enabled).toBe(true);
|
expect(before.enabled).toBe(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user