docs(self-host): sovereign Sverige guide, backup/restore scripts, Speed Insights gate (#1744)

docs/SOVEREIGN.md (run Accounted on Swedish infrastructure: providers, self-hosted Supabase gotchas, backup/restore runbook, honest dependency list), scripts/self-host/backup.sh + restore.sh (pg_dump custom format, storage tar, SHA-256 manifest, S3-compatible upload; ACLs are preserved through the restore and re-verified against an acl-manifest including sequences; the resume hook always runs after a failed quiesce and the hooks must be configured as a pair), Vercel Speed Insights gated off for self-hosted, and stale self-host docs corrected (assistant Q&A and categorization run on BYO OpenAI-compatible models; SMTP via EMAIL_PROVIDER=smtp after #1746; connector subscription described as proposed only).
This commit is contained in:
Jakob Wennberg
2026-08-31 08:18:14 +01:00
committed by GitHub
parent dd8c06a8fa
commit 79edee659f
10 changed files with 963 additions and 16 deletions
@@ -0,0 +1,223 @@
import { describe, it, expect } from 'vitest'
import { execFileSync, type ExecFileSyncOptions } from 'node:child_process'
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// The self-host backup/restore scripts are shipped product (docs/SOVEREIGN.md
// tells operators to run them on a schedule), so at least their syntax and
// their refusal paths are checked in CI. Behaviour against a real bucket and
// database is exercised by the operator's first dry run per the runbook.
const DIR = join(__dirname, '..')
const SCRIPTS = ['backup.sh', 'restore.sh']
// A clean environment: none of the BACKUP_* / RESTORE_* variables, so the
// scripts' own guards are what runs. Typed as ProcessEnv (the repo's
// augmentation makes NODE_ENV a required key) so it can be spread into the
// per-test environments below without a cast.
const BARE_PROCESS_ENV: NodeJS.ProcessEnv = { PATH: process.env.PATH ?? '', NODE_ENV: 'test' }
const BARE_ENV: ExecFileSyncOptions = { env: BARE_PROCESS_ENV, stdio: 'pipe' }
// Enough environment for backup.sh to get past its required-variable guards;
// nothing here is ever contacted (the tests below stop before any tool runs).
const BACKUP_REQUIRED_ENV: NodeJS.ProcessEnv = {
...BARE_PROCESS_ENV,
BACKUP_DATABASE_URL: 'postgresql://postgres:x@127.0.0.1:1/postgres',
BACKUP_S3_ENDPOINT: 'https://s3.invalid',
BACKUP_S3_BUCKET: 'bucket',
AWS_ACCESS_KEY_ID: 'key',
AWS_SECRET_ACCESS_KEY: 'secret',
}
function runBash(args: string[], env: NodeJS.ProcessEnv): { status: number; stderr: string } {
try {
execFileSync('bash', args, { env, stdio: 'pipe' })
return { status: 0, stderr: '' }
} catch (err) {
return {
status: (err as { status?: number }).status ?? -1,
stderr: String((err as { stderr?: Buffer }).stderr ?? ''),
}
}
}
describe('self-host shell scripts', () => {
for (const name of SCRIPTS) {
it(`${name} parses under bash -n`, () => {
expect(() => execFileSync('bash', ['-n', join(DIR, name)])).not.toThrow()
})
it(`${name} sets strict mode and a private umask`, () => {
const src = readFileSync(join(DIR, name), 'utf8')
expect(src).toContain('set -euo pipefail')
expect(src).toContain('umask 077')
})
it(`${name} keeps ACLs in the dump (no --no-privileges), drops ownership (--no-owner), uses the ACL manifest`, () => {
// The migrations REVOKE hardened SECURITY DEFINER RPCs from anon and
// authenticated (REVOKE ... FROM PUBLIC, anon; GRANT ... TO service_role).
// --no-privileges would strip that from the dump and a restored self-host
// would re-expose those RPCs over PostgREST; --no-owner is what lets the
// dump land in a stack whose roles were created by that stack.
const src = readFileSync(join(DIR, name), 'utf8')
// The invocation lines, not the comments that explain them.
const commands = src.split('\n').filter((line) => /^\s*pg_(dump|restore) /.test(line))
expect(commands.length).toBeGreaterThan(0)
for (const command of commands) {
expect(command).toContain('--no-owner')
expect(command).not.toContain('--no-privileges')
}
expect(src).toContain('acl-manifest.sql')
})
}
it('restore.sh neutralizes the restoring role default privileges before pg_restore and diffs the ACL manifest after it', () => {
// pg_dump writes ACLs as a diff against acldefault(), so the dump never
// says "REVOKE FROM anon"; a Supabase target's ALTER DEFAULT PRIVILEGES
// would hand anon EXECUTE back to every restored function unless those
// defaults are removed first. The manifest diff is what makes a restore
// that lost the hardening fail instead of pass silently.
const src = readFileSync(join(DIR, 'restore.sh'), 'utf8')
const neutralize = src.indexOf('ALTER DEFAULT PRIVILEGES')
const restore = src.indexOf('pg_restore --clean')
const check = src.lastIndexOf('acl-manifest.sql')
expect(neutralize).toBeGreaterThan(-1)
expect(restore).toBeGreaterThan(neutralize)
expect(check).toBeGreaterThan(restore)
// Exactly the built-in default, not "no grants at all": a global entry
// that took EXECUTE on functions away from PUBLIC gets it back, otherwise
// a function the source never touched (NULL ACL, PUBLIC-executable)
// restores as non-executable.
expect(src).toContain('TO PUBLIC')
expect(src).toContain('ACL MISMATCH')
})
it('acl-manifest.sql states every PostgREST role for functions, relations and sequences in public', () => {
const sql = readFileSync(join(DIR, 'acl-manifest.sql'), 'utf8')
for (const role of ['anon', 'authenticated', 'service_role']) {
expect(sql).toContain(`has_function_privilege('${role}'`)
expect(sql).toContain(`has_table_privilege('${role}'`)
// Sequences have their own privilege set (USAGE, SELECT, UPDATE) that
// has_table_privilege does not see; a restore that hands anon nextval
// on an id sequence must show up in the diff like any other object.
for (const privilege of ['USAGE', 'SELECT', 'UPDATE']) {
expect(sql).toContain(`has_sequence_privilege('${role}', c.oid, '${privilege}')`)
}
}
expect(sql).toMatch(/relkind = 'S'/)
expect(sql).toContain("'public'::regnamespace")
// Byte-identical output on both sides regardless of database collation.
expect(sql).toContain('collate "C"')
// restore.sh shows the mismatching lines by object kind; a sequence line
// must not be filtered out of that excerpt.
const restore = readFileSync(join(DIR, 'restore.sh'), 'utf8')
expect(restore).toContain('(function|relation|sequence) ')
})
it('restore.sh does not require RESTORE_DATABASE_URL for the db-config pass (RESTORE_SKIP_DATABASE=1)', () => {
let status = 0
let stderr = ''
try {
execFileSync('bash', [join(DIR, 'restore.sh'), 'nightly-20260101T000000Z', '--yes'], {
...BARE_ENV,
env: { ...BARE_PROCESS_ENV, RESTORE_SKIP_DATABASE: '1' },
})
} catch (err) {
status = (err as { status?: number }).status ?? -1
stderr = String((err as { stderr?: Buffer }).stderr ?? '')
}
expect(status).not.toBe(0)
expect(stderr).not.toContain('RESTORE_DATABASE_URL')
expect(stderr).toContain('BACKUP_S3_ENDPOINT is required')
})
it('backup.sh refuses to run without the required environment', () => {
let status = 0
try {
execFileSync('bash', [join(DIR, 'backup.sh')], BARE_ENV)
} catch (err) {
status = (err as { status?: number }).status ?? -1
}
expect(status).not.toBe(0)
})
it('backup.sh refuses a quiesce hook without its resume hook, and the reverse, before running anything', () => {
// A quiesce command with no resume command would leave the operator's app
// stopped after every run; refusing up front is the only safe answer.
const quiesceOnly = runBash([join(DIR, 'backup.sh')], { ...BACKUP_REQUIRED_ENV, BACKUP_QUIESCE_CMD: 'true' })
expect(quiesceOnly.status).toBe(2)
expect(quiesceOnly.stderr).toContain('BACKUP_QUIESCE_CMD is set but BACKUP_RESUME_CMD is not')
const resumeOnly = runBash([join(DIR, 'backup.sh')], { ...BACKUP_REQUIRED_ENV, BACKUP_RESUME_CMD: 'true' })
expect(resumeOnly.status).toBe(2)
expect(resumeOnly.stderr).toContain('BACKUP_RESUME_CMD is set but BACKUP_QUIESCE_CMD is not')
})
it('backup.sh runs the resume hook when the quiesce hook fails part-way', () => {
// `docker compose stop app cron` can stop `app` and then fail on `cron`;
// set -e ends the script right there, and the EXIT trap must still run
// the resume hook or the operator's app stays down after a failed backup.
// The required tools are stubbed on PATH so the script reaches the hook;
// none of them is ever executed because the hook fails first.
const stubs = mkdtempSync(join(tmpdir(), 'accounted-backup-stubs-'))
try {
for (const tool of ['pg_dump', 'psql', 'tar', 'gzip', 'aws']) {
const stub = join(stubs, tool)
writeFileSync(stub, '#!/bin/sh\nexit 0\n')
chmodSync(stub, 0o755)
}
const marker = join(stubs, 'resumed')
const result = runBash([join(DIR, 'backup.sh')], {
...BACKUP_REQUIRED_ENV,
PATH: `${stubs}:${process.env.PATH ?? ''}`,
BACKUP_QUIESCE_CMD: 'exit 3',
BACKUP_RESUME_CMD: `touch "${marker}"`,
})
expect(result.status).not.toBe(0)
expect(existsSync(marker)).toBe(true)
} finally {
rmSync(stubs, { recursive: true, force: true })
}
})
it('backup.sh marks the quiesce attempt before running the hook, not after it succeeds', () => {
const src = readFileSync(join(DIR, 'backup.sh'), 'utf8')
const attempted = src.indexOf('QUIESCE_ATTEMPTED=1')
const hook = src.indexOf('bash -c "$BACKUP_QUIESCE_CMD"')
expect(attempted).toBeGreaterThan(-1)
expect(hook).toBeGreaterThan(attempted)
})
it('restore.sh rejects a backup name with shell metacharacters before doing anything', () => {
let status = 0
let stderr = ''
try {
execFileSync('bash', [join(DIR, 'restore.sh'), 'nightly-2026; rm -rf /', '--yes'], BARE_ENV)
} catch (err) {
status = (err as { status?: number }).status ?? -1
stderr = String((err as { stderr?: Buffer }).stderr ?? '')
}
expect(status).toBe(2)
expect(stderr).toContain('invalid backup name')
})
it('neither script runs a shell inside the docker helper container', () => {
// `docker run ... sh -c "<string with ${NAME}>"` would let a crafted
// backup name execute inside the container; tar must get the path as a
// direct argument. (The operator-supplied quiesce/resume hooks in
// backup.sh run through bash on the host by design; they are config.)
const dockerShell = /docker run[^\n]*(\\\n[^\n]*)*?\bsh -c/
expect(readFileSync(join(DIR, 'restore.sh'), 'utf8')).not.toMatch(dockerShell)
expect(readFileSync(join(DIR, 'backup.sh'), 'utf8')).not.toMatch(dockerShell)
})
it('restore.sh refuses to run without --yes', () => {
let status = 0
let stderr = ''
try {
execFileSync('bash', [join(DIR, 'restore.sh'), 'nightly-20260101T000000Z'], BARE_ENV)
} catch (err) {
status = (err as { status?: number }).status ?? -1
stderr = String((err as { stderr?: Buffer }).stderr ?? '')
}
expect(status).toBe(2)
expect(stderr).toContain('--yes')
})
})
+67
View File
@@ -0,0 +1,67 @@
-- ACL manifest of the application schema: one line per function, relation
-- and sequence in `public`, stating what each PostgREST role (anon,
-- authenticated, service_role) may do with it. backup.sh writes this next to the dump;
-- restore.sh runs it again after pg_restore and diffs the two. Any difference
-- means the restored database exposes an object differently than the source
-- did, which is exactly the failure a restore drill must surface (the
-- migrations lock SECURITY DEFINER RPCs away from anon/authenticated, and a
-- restore that loses those REVOKEs is silent otherwise).
--
-- Deliberately owner-independent: --no-owner changes who owns the objects,
-- never what these three roles may do. Run with psql -X -A -t -q. Ordered
-- under the C collation so a source and a target with different database
-- collations still produce byte-identical files.
set search_path = public, pg_catalog;
select line from (
select 'function ' || p.oid::regprocedure::text
|| ' anon=' || has_function_privilege('anon', p.oid, 'EXECUTE')::int
|| ' authenticated=' || has_function_privilege('authenticated', p.oid, 'EXECUTE')::int
|| ' service_role=' || has_function_privilege('service_role', p.oid, 'EXECUTE')::int as line
from pg_proc p
where p.pronamespace = 'public'::regnamespace
union all
-- Four digits per role: SELECT INSERT UPDATE DELETE.
select 'relation ' || c.oid::regclass::text
|| ' anon='
|| has_table_privilege('anon', c.oid, 'SELECT')::int
|| has_table_privilege('anon', c.oid, 'INSERT')::int
|| has_table_privilege('anon', c.oid, 'UPDATE')::int
|| has_table_privilege('anon', c.oid, 'DELETE')::int
|| ' authenticated='
|| has_table_privilege('authenticated', c.oid, 'SELECT')::int
|| has_table_privilege('authenticated', c.oid, 'INSERT')::int
|| has_table_privilege('authenticated', c.oid, 'UPDATE')::int
|| has_table_privilege('authenticated', c.oid, 'DELETE')::int
|| ' service_role='
|| has_table_privilege('service_role', c.oid, 'SELECT')::int
|| has_table_privilege('service_role', c.oid, 'INSERT')::int
|| has_table_privilege('service_role', c.oid, 'UPDATE')::int
|| has_table_privilege('service_role', c.oid, 'DELETE')::int
from pg_class c
where c.relnamespace = 'public'::regnamespace
and c.relkind in ('r', 'p', 'v', 'm')
union all
-- Sequences carry their own privilege set (has_table_privilege does not
-- see them), and the Supabase defaults grant them to every PostgREST role:
-- a restore that changes them (nextval on an id sequence for anon, say)
-- must fail the diff like any other object. Three digits per role:
-- USAGE SELECT UPDATE.
select 'sequence ' || c.oid::regclass::text
|| ' anon='
|| has_sequence_privilege('anon', c.oid, 'USAGE')::int
|| has_sequence_privilege('anon', c.oid, 'SELECT')::int
|| has_sequence_privilege('anon', c.oid, 'UPDATE')::int
|| ' authenticated='
|| has_sequence_privilege('authenticated', c.oid, 'USAGE')::int
|| has_sequence_privilege('authenticated', c.oid, 'SELECT')::int
|| has_sequence_privilege('authenticated', c.oid, 'UPDATE')::int
|| ' service_role='
|| has_sequence_privilege('service_role', c.oid, 'USAGE')::int
|| has_sequence_privilege('service_role', c.oid, 'SELECT')::int
|| has_sequence_privilege('service_role', c.oid, 'UPDATE')::int
from pg_class c
where c.relnamespace = 'public'::regnamespace
and c.relkind = 'S'
) t
order by line collate "C";
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env bash
# Accounted self-host backup: logical database dump + document storage, shipped
# to an S3-compatible bucket, optionally under S3 Object Lock (WORM).
#
# Self-hosted Supabase has no managed backups or PITR, and Swedish bookkeeping
# law (BFL 7 kap) requires the ledger and its underlag (receipts, invoices) to
# be kept for seven years after the end of the fiscal year. This script is the
# minimum that satisfies both: one restorable dump per run, and the documents
# bucket alongside it, on storage the operator controls. See
# docs/SOVEREIGN.md ("Backup and restore") for the runbook and the cron line.
#
# Requirements on the host running it: bash, pg_dump and psql (matching the
# server's major version), tar, gzip, sha256sum (or shasum), AWS CLI v2 (works
# against any S3-compatible endpoint: Safespring, GleSYS, Elastx via
# --endpoint-url).
#
# Environment (required unless marked optional):
# BACKUP_DATABASE_URL postgresql://postgres:<password>@<host>:<port>/postgres
# Use the Supabase session-mode pooler port or the
# db container's port; never a public address.
# BACKUP_S3_ENDPOINT e.g. https://s3.sto2.safedc.net
# BACKUP_S3_BUCKET bucket name (create it with Object Lock enabled:
# Object Lock can only be turned on at creation)
# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY credentials for that bucket
# BACKUP_S3_REGION optional, default us-east-1 (what most S3-compatible
# endpoints expect for SigV4; Elastx requires it)
# BACKUP_S3_PREFIX optional, default "accounted"
# BACKUP_STORAGE_DIR optional: path of the Supabase storage volume
# (STORAGE_BACKEND=file: <supabase-dir>/volumes/storage).
# Omit when storage-api writes straight to S3; then
# back that bucket up bucket-to-bucket instead.
# BACKUP_DB_CONFIG_VOLUME optional: name of the Supabase `db-config` Docker
# volume (e.g. supabase_db-config). It holds the
# pgsodium root key; a dump restored without it
# cannot decrypt Vault secrets. Requires docker.
# BACKUP_OBJECT_LOCK_DAYS optional: when set, every uploaded object gets
# COMPLIANCE-mode retention for this many days
# (immutable even for the bucket owner). Suggested:
# a long value (>= 2600, seven years plus margin)
# for the yearly post-bokslut run, a short one for
# nightly runs, or unset and rely on bucket default
# retention. COMPLIANCE retention cannot be shortened.
# BACKUP_WORKDIR optional: scratch directory, default mktemp.
# BACKUP_LABEL optional: name fragment, default "nightly".
# BACKUP_QUIESCE_CMD optional: a command run BEFORE the dump and
# BACKUP_RESUME_CMD AFTER the upload (also on failure), e.g.
# "docker compose -f /opt/accounted/docker-compose.yml stop app cron"
# and the matching "start". The database dump and
# the storage tar are taken one after the other;
# an upload landing between them leaves a document
# row without its file (or the reverse) in that
# set. Stopping the app for the window makes the
# set consistent; recommended for the yearly
# archive run, optional for nightly runs where the
# next night's set covers the gap. Set both or
# neither: one without the other is refused
# before anything runs.
#
# Prints a short progress log on stdout; exit status is non-zero on any
# failure, which is what a cron wrapper should alert on.
set -euo pipefail
# Dumps, archives and manifests are the whole ledger: never world-readable,
# whatever the operator's default umask is.
umask 077
: "${BACKUP_DATABASE_URL:?BACKUP_DATABASE_URL is required}"
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT is required}"
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET is required}"
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required}"
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required}"
# The hooks are a pair. A quiesce command without its resume command would
# leave the operator's app stopped after every run (and a resume command
# without a quiesce command would start containers nobody stopped), so refuse
# before anything else happens.
if [ -n "${BACKUP_QUIESCE_CMD:-}" ] && [ -z "${BACKUP_RESUME_CMD:-}" ]; then
echo "backup: BACKUP_QUIESCE_CMD is set but BACKUP_RESUME_CMD is not; set both or neither" >&2
exit 2
fi
if [ -n "${BACKUP_RESUME_CMD:-}" ] && [ -z "${BACKUP_QUIESCE_CMD:-}" ]; then
echo "backup: BACKUP_RESUME_CMD is set but BACKUP_QUIESCE_CMD is not; set both or neither" >&2
exit 2
fi
export AWS_DEFAULT_REGION="${BACKUP_S3_REGION:-us-east-1}"
PREFIX="${BACKUP_S3_PREFIX:-accounted}"
LABEL="${BACKUP_LABEL:-nightly}"
# The label becomes S3 keys, file names and container arguments: keep it to
# the same character set restore.sh accepts.
if ! [[ "$LABEL" =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "backup: invalid BACKUP_LABEL \"$LABEL\" (letters, digits, . _ - only)" >&2
exit 2
fi
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
NAME="${LABEL}-${STAMP}"
for bin in pg_dump psql tar gzip aws; do
command -v "$bin" >/dev/null 2>&1 || { echo "backup: missing required tool: $bin" >&2; exit 2; }
done
if command -v sha256sum >/dev/null 2>&1; then SHA="sha256sum"; else SHA="shasum -a 256"; fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WORK="${BACKUP_WORKDIR:-$(mktemp -d "${TMPDIR:-/tmp}/accounted-backup.XXXXXX")}"
mkdir -p "$WORK"
chmod 700 "$WORK"
# Flipped to 1 BEFORE the quiesce hook runs, not after it succeeds: a hook
# that stops one container and then fails ends the script (set -e), and the
# EXIT trap must still run the resume hook, otherwise a failed backup leaves
# the app stopped.
QUIESCE_ATTEMPTED=0
cleanup() {
if [ "$QUIESCE_ATTEMPTED" = 1 ] && [ -n "${BACKUP_RESUME_CMD:-}" ]; then
bash -c "$BACKUP_RESUME_CMD" || echo "backup: BACKUP_RESUME_CMD failed; check that the app is running" >&2
fi
[ -z "${BACKUP_WORKDIR:-}" ] && rm -rf "$WORK"
}
trap cleanup EXIT
upload() {
# upload <local-file> <key>
local file="$1" key="$2"
local args=(s3api put-object --endpoint-url "$BACKUP_S3_ENDPOINT" --bucket "$BACKUP_S3_BUCKET" --key "$key" --body "$file")
if [ -n "${BACKUP_OBJECT_LOCK_DAYS:-}" ]; then
local until
# GNU date and BSD date differ; try GNU first.
until="$(date -u -d "+${BACKUP_OBJECT_LOCK_DAYS} days" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v+"${BACKUP_OBJECT_LOCK_DAYS}"d +%Y-%m-%dT%H:%M:%SZ)"
args+=(--object-lock-mode COMPLIANCE --object-lock-retain-until-date "$until")
fi
aws "${args[@]}" >/dev/null
echo "backup: uploaded s3://${BACKUP_S3_BUCKET}/${key}"
}
echo "backup: starting ${NAME}"
if [ -n "${BACKUP_QUIESCE_CMD:-}" ]; then
QUIESCE_ATTEMPTED=1
bash -c "$BACKUP_QUIESCE_CMD"
echo "backup: application quiesced for a consistent set"
fi
# 1. Database: custom-format dump (compressed, selective restore possible).
# --no-owner so it restores into a fresh Supabase stack whose roles were
# created by that stack, not by us. ACLs (GRANT/REVOKE) are kept (no
# --no-privileges): the migrations lock SECURITY DEFINER RPCs away from
# anon/authenticated, and the dump is the only record of that. Keeping them
# is necessary but not sufficient: pg_dump writes ACLs as a diff against
# PostgreSQL's built-in defaults, so a role-specific "REVOKE ... FROM anon"
# is not in the dump at all, and a Supabase target's ALTER DEFAULT
# PRIVILEGES would hand anon/authenticated EXECUTE back to every restored
# function. restore.sh neutralizes those default privileges before
# pg_restore and then diffs the ACL manifest taken in step 1b against the
# restored database, so a re-exposure fails the restore instead of hiding.
DB_FILE="${WORK}/${NAME}.db.dump"
pg_dump --format=custom --no-owner --file "$DB_FILE" "$BACKUP_DATABASE_URL"
echo "backup: database dump $(du -h "$DB_FILE" | cut -f1)"
# 1b. ACL manifest: what anon/authenticated/service_role may do with every
# function, relation and sequence in public
# (scripts/self-host/acl-manifest.sql).
# restore.sh compares the restored database against this file.
ACL_FILE="${WORK}/${NAME}.acl.txt"
psql -X -A -t -q -v ON_ERROR_STOP=1 -f "${SCRIPT_DIR}/acl-manifest.sql" "$BACKUP_DATABASE_URL" > "$ACL_FILE"
echo "backup: ACL manifest $(wc -l < "$ACL_FILE" | tr -d ' ') objects"
# 2. Documents (the BFL underlag) when storage-api uses the file backend.
STORAGE_FILE=""
if [ -n "${BACKUP_STORAGE_DIR:-}" ]; then
if [ ! -d "$BACKUP_STORAGE_DIR" ]; then
echo "backup: BACKUP_STORAGE_DIR does not exist: $BACKUP_STORAGE_DIR" >&2
exit 2
fi
STORAGE_FILE="${WORK}/${NAME}.storage.tar.gz"
tar -C "$BACKUP_STORAGE_DIR" -czf "$STORAGE_FILE" .
echo "backup: storage archive $(du -h "$STORAGE_FILE" | cut -f1)"
fi
# 3. Supabase db-config volume (pgsodium root key), optional but strongly
# recommended: without it Vault-encrypted columns in the dump are
# unreadable after a restore.
DBCONFIG_FILE=""
if [ -n "${BACKUP_DB_CONFIG_VOLUME:-}" ]; then
command -v docker >/dev/null 2>&1 || { echo "backup: BACKUP_DB_CONFIG_VOLUME set but docker not found" >&2; exit 2; }
DBCONFIG_FILE="${WORK}/${NAME}.db-config.tar.gz"
docker run --rm -v "${BACKUP_DB_CONFIG_VOLUME}:/src:ro" -v "${WORK}:/out" alpine:3 \
tar -C /src -czf "/out/$(basename "$DBCONFIG_FILE")" .
echo "backup: db-config archive $(du -h "$DBCONFIG_FILE" | cut -f1)"
fi
# 4. Checksums, then upload everything under one prefix.
MANIFEST="${WORK}/${NAME}.sha256"
( cd "$WORK" && $SHA "$(basename "$DB_FILE")" "$(basename "$ACL_FILE")" \
${STORAGE_FILE:+"$(basename "$STORAGE_FILE")"} \
${DBCONFIG_FILE:+"$(basename "$DBCONFIG_FILE")"} > "$MANIFEST" )
for f in "$DB_FILE" "$ACL_FILE" ${STORAGE_FILE:+"$STORAGE_FILE"} ${DBCONFIG_FILE:+"$DBCONFIG_FILE"} "$MANIFEST"; do
upload "$f" "${PREFIX}/${NAME}/$(basename "$f")"
done
echo "backup: done ${NAME}"
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env bash
# Accounted self-host restore: the counterpart of backup.sh.
#
# Restores one backup set (database dump, ACL manifest, optional storage
# archive, optional db-config archive) from the S3-compatible bucket into a
# target Postgres and storage directory. Destructive by design: it drops and
# recreates the objects in the target database (--clean). Run it against a
# FRESH Supabase stack, or one you are prepared to overwrite, and pass --yes.
#
# Usage:
# scripts/self-host/restore.sh <backup-name> --yes
# e.g. scripts/self-host/restore.sh nightly-20260820T020000Z --yes
#
# Environment:
# RESTORE_DATABASE_URL target postgresql://... (required unless
# RESTORE_SKIP_DATABASE=1). Connect as the stack's
# `postgres` role, the one the migrations ran as.
# BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, AWS_ACCESS_KEY_ID,
# AWS_SECRET_ACCESS_KEY, BACKUP_S3_REGION, BACKUP_S3_PREFIX as in backup.sh
# RESTORE_STORAGE_DIR optional: where to unpack the storage archive
# (the new stack's <supabase-dir>/volumes/storage).
# RESTORE_DB_CONFIG_VOLUME optional: Docker volume name to unpack the
# db-config archive into. The database container
# must be STOPPED for this and started afterwards,
# so do it as its own pass with
# RESTORE_SKIP_DATABASE=1 (see the runbook).
# RESTORE_SKIP_DATABASE optional: set to 1 to skip pg_restore and the ACL
# check (the db-config pass above).
# RESTORE_WORKDIR optional scratch dir.
# RESTORE_TOLERATE_ERRORS optional: set to 1 to continue when pg_restore
# reports errors (exit status 1). Default is to
# STOP before the ACL check and storage and show
# the error log. On a Supabase target these
# classes are routine (the stack already owns and
# grants those objects; the restoring role is not a
# superuser): "already exists"; "does not exist"
# from DROP ... IF EXISTS of policies/triggers on
# a fresh target; "must be member of role
# supabase_*", "must be owner of ...", "permission
# denied ..." and "grant options cannot be granted
# back" on GRANT/REVOKE/ALTER for objects in
# auth, storage, realtime, extensions, cron, vault,
# graphql*, pgbouncer. Errors on public.* objects
# are NOT expected: investigate before continuing.
# A partial restore must be a decision you take
# knowingly, not a default.
#
# Order that works: (1) bring up a fresh Supabase stack with the SAME
# JWT_SECRET / ANON_KEY / SERVICE_ROLE_KEY as the old one (or re-issue keys to
# your Accounted .env), (2) stop the database container and restore db-config
# (RESTORE_DB_CONFIG_VOLUME + RESTORE_SKIP_DATABASE=1), (3) start it and
# restore the database and storage (second run), (4) restart storage-api,
# (5) run `scripts/smoke-ai-provider.ts`-style checks and log in.
set -euo pipefail
# Downloaded dumps are the whole ledger: never world-readable.
umask 077
NAME="${1:-}"
CONFIRM="${2:-}"
if [ -z "$NAME" ] || [ "$NAME" = "--help" ]; then
sed -n '2,/^set -euo pipefail/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'
exit 2
fi
if [ "$CONFIRM" != "--yes" ]; then
echo "restore: refusing to run without --yes (this overwrites the target database)" >&2
exit 2
fi
# The name becomes S3 keys, local file names and container arguments: only
# the characters backup.sh can produce are accepted, so nothing shell- or
# path-like ever reaches those places.
if ! [[ "$NAME" =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "restore: invalid backup name \"$NAME\" (expected e.g. nightly-20260820T020000Z)" >&2
exit 2
fi
SKIP_DB="${RESTORE_SKIP_DATABASE:-0}"
if [ "$SKIP_DB" != "1" ]; then
: "${RESTORE_DATABASE_URL:?RESTORE_DATABASE_URL is required (or set RESTORE_SKIP_DATABASE=1)}"
fi
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT is required}"
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET is required}"
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required}"
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required}"
export AWS_DEFAULT_REGION="${BACKUP_S3_REGION:-us-east-1}"
PREFIX="${BACKUP_S3_PREFIX:-accounted}"
for bin in pg_restore psql tar gzip aws; do
command -v "$bin" >/dev/null 2>&1 || { echo "restore: missing required tool: $bin" >&2; exit 2; }
done
if command -v sha256sum >/dev/null 2>&1; then SHA="sha256sum"; else SHA="shasum -a 256"; fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WORK="${RESTORE_WORKDIR:-$(mktemp -d "${TMPDIR:-/tmp}/accounted-restore.XXXXXX")}"
mkdir -p "$WORK"
chmod 700 "$WORK"
cleanup() { [ -z "${RESTORE_WORKDIR:-}" ] && rm -rf "$WORK"; }
trap cleanup EXIT
fetch() {
# fetch <file-name> -> downloads into $WORK, returns 1 when the key is absent
aws s3api get-object --endpoint-url "$BACKUP_S3_ENDPOINT" --bucket "$BACKUP_S3_BUCKET" \
--key "${PREFIX}/${NAME}/$1" "${WORK}/$1" >/dev/null 2>&1
}
echo "restore: fetching ${NAME} from s3://${BACKUP_S3_BUCKET}/${PREFIX}/${NAME}/"
fetch "${NAME}.sha256" || { echo "restore: no manifest found for ${NAME}" >&2; exit 1; }
fetch "${NAME}.db.dump" || { echo "restore: database dump missing" >&2; exit 1; }
HAVE_ACL=0; fetch "${NAME}.acl.txt" && HAVE_ACL=1
HAVE_STORAGE=0; fetch "${NAME}.storage.tar.gz" && HAVE_STORAGE=1
HAVE_DBCONFIG=0; fetch "${NAME}.db-config.tar.gz" && HAVE_DBCONFIG=1
# Verify every file the manifest lists before touching anything.
( cd "$WORK" && $SHA -c "${NAME}.sha256" )
echo "restore: checksums verified"
if [ "$HAVE_DBCONFIG" = 1 ] && [ -n "${RESTORE_DB_CONFIG_VOLUME:-}" ]; then
command -v docker >/dev/null 2>&1 || { echo "restore: RESTORE_DB_CONFIG_VOLUME set but docker not found" >&2; exit 2; }
# No shell inside the container: tar receives the path as an argument.
docker run --rm -v "${RESTORE_DB_CONFIG_VOLUME}:/dst" -v "${WORK}:/in:ro" alpine:3 \
tar -C /dst -xzf "/in/${NAME}.db-config.tar.gz"
echo "restore: db-config volume restored (${RESTORE_DB_CONFIG_VOLUME}); start the database container before the database pass"
fi
if [ "$SKIP_DB" = "1" ]; then
echo "restore: RESTORE_SKIP_DATABASE=1, database not touched"
else
# Log only what follows the last "@" (host, port, database): never the
# credentials part of the URL.
echo "restore: restoring database into ${RESTORE_DATABASE_URL##*@} (objects are dropped and recreated)"
# The dump carries ACLs, but pg_dump writes them as a diff against
# PostgreSQL's built-in defaults (acldefault), never against the target's
# ALTER DEFAULT PRIVILEGES. A Supabase stack grants anon, authenticated and
# service_role on every new function/table/sequence the postgres role
# creates, so restoring into it would hand anon/authenticated EXECUTE back
# to every hardened RPC: the dump only says "REVOKE FROM PUBLIC; GRANT TO
# service_role" and never "REVOKE FROM anon". Put the restoring role's
# default privileges back to exactly PostgreSQL's built-in defaults right
# before pg_restore (revoke every added grantee; PUBLIC back to EXECUTE on
# functions and USAGE on types, nothing on tables and sequences), so the
# dump's explicit GRANT/REVOKE statements apply to the base they were
# computed against. The dump's own last section (DEFAULT ACL) re-creates
# the stack's default privileges once every object exists, so migrations
# applied later still get the grants PostgREST needs; the check after
# pg_restore confirms that.
psql -X -q -v ON_ERROR_STOP=1 "$RESTORE_DATABASE_URL" <<'SQL'
DO $$
DECLARE
r record;
scope text;
kind text;
BEGIN
-- 1. Every grantee the stack added to the defaults (anon, authenticated,
-- service_role, PUBLIC on tables, ...): revoke it. The role's own
-- privileges are left alone. IN SCHEMA entries are additions on top of
-- the built-in default and PostgreSQL drops them once they are empty.
FOR r IN
SELECT DISTINCT d.defaclobjtype AS objtype, d.defaclnamespace AS nsp, a.grantee
FROM pg_default_acl d
CROSS JOIN LATERAL aclexplode(d.defaclacl) a
WHERE d.defaclrole = (SELECT oid FROM pg_roles WHERE rolname = current_user)
AND d.defaclnamespace IN (0, 'public'::regnamespace)
AND a.grantee <> d.defaclrole
LOOP
scope := CASE WHEN r.nsp = 0 THEN '' ELSE 'IN SCHEMA public' END;
kind := CASE r.objtype
WHEN 'r' THEN 'TABLES' WHEN 'S' THEN 'SEQUENCES' WHEN 'f' THEN 'FUNCTIONS'
WHEN 'T' THEN 'TYPES' WHEN 'n' THEN 'SCHEMAS'
END;
EXECUTE format('ALTER DEFAULT PRIVILEGES %s REVOKE ALL ON %s FROM %s', scope, kind,
CASE WHEN r.grantee = 0 THEN 'PUBLIC' ELSE r.grantee::regrole::text END);
END LOOP;
-- 2. A global entry (no schema) REPLACES the built-in default, so one that
-- took EXECUTE on functions or USAGE on types away from PUBLIC would
-- still be in force. Grant those back: an entry equal to the built-in
-- default is removed by PostgreSQL itself. (The Supabase image has no
-- global entries for postgres; an operator-hardened stack may.)
FOR r IN
SELECT DISTINCT d.defaclobjtype AS objtype
FROM pg_default_acl d
WHERE d.defaclrole = (SELECT oid FROM pg_roles WHERE rolname = current_user)
AND d.defaclnamespace = 0
AND d.defaclobjtype IN ('f', 'T')
LOOP
EXECUTE format('ALTER DEFAULT PRIVILEGES GRANT %s ON %s TO PUBLIC',
CASE WHEN r.objtype = 'f' THEN 'EXECUTE' ELSE 'USAGE' END,
CASE WHEN r.objtype = 'f' THEN 'FUNCTIONS' ELSE 'TYPES' END);
END LOOP;
END $$;
SQL
echo "restore: default privileges of the restoring role neutralized for the restore"
# --clean --if-exists: drop objects before recreating them. --no-owner: the
# fresh stack owns its roles. No --no-privileges: the ACLs are the point.
# pg_restore exits 1 when any restore operation failed; on a Supabase
# target the classes listed in the header are routine (stack-owned objects
# the non-superuser postgres role may not drop or re-grant), a missing
# table or an error on a public.* object is not, and the script cannot
# tell which. Default: stop here, show the log, restore nothing further.
# The operator reads the log and re-runs with RESTORE_TOLERATE_ERRORS=1 if
# the errors are the expected kind.
PG_LOG="${WORK}/pg_restore.log"
set +e
pg_restore --clean --if-exists --no-owner --dbname "$RESTORE_DATABASE_URL" \
"${WORK}/${NAME}.db.dump" 2> "$PG_LOG"
rc=$?
set -e
if [ "$rc" -ne 0 ]; then
echo "restore: pg_restore exited with status ${rc}; errors reported:" >&2
grep -E "^pg_restore: (error|warning)" "$PG_LOG" | head -40 >&2 || tail -40 "$PG_LOG" >&2
if [ "$rc" -ne 1 ] || [ "${RESTORE_TOLERATE_ERRORS:-0}" != "1" ]; then
echo "restore: stopping before the ACL check and storage (the db-config volume, if requested, is already unpacked). Inspect the errors above; if they are the expected kind on a Supabase target (\"already exists\", \"does not exist\" from DROP IF EXISTS, and permission/ownership/grant errors on objects in auth, storage, realtime, extensions, cron, vault, graphql*, pgbouncer, never on public.*), re-run with RESTORE_TOLERATE_ERRORS=1." >&2
exit "$rc"
fi
echo "restore: continuing despite pg_restore errors (RESTORE_TOLERATE_ERRORS=1)" >&2
fi
echo "restore: database restored (pg_restore status ${rc})"
# ACL check: the manifest backup.sh took from the source must match the
# restored database line for line. A difference means an object is now
# reachable by a PostgREST role that could not reach it before (or the
# reverse), and there is no override: fix the ACLs by hand from the diff
# (GRANT/REVOKE on the named objects) or re-run into a fresh stack.
if [ "$HAVE_ACL" = 1 ]; then
ACL_NOW="${WORK}/${NAME}.acl.restored.txt"
psql -X -A -t -q -v ON_ERROR_STOP=1 -f "${SCRIPT_DIR}/acl-manifest.sql" "$RESTORE_DATABASE_URL" > "$ACL_NOW"
if ! diff -u "${WORK}/${NAME}.acl.txt" "$ACL_NOW" > "${WORK}/acl.diff"; then
echo "restore: ACL MISMATCH between the source manifest (-) and the restored database (+):" >&2
grep -E "^[-+](function|relation|sequence) " "${WORK}/acl.diff" | head -60 >&2
echo "restore: stopping before storage. The restored database does not grant the PostgREST roles what the source did; see the lines above." >&2
exit 1
fi
echo "restore: ACL manifest verified ($(wc -l < "$ACL_NOW" | tr -d ' ') objects, identical to the source)"
else
echo "restore: no ACL manifest in this set (older backup.sh); ACL verification skipped" >&2
fi
# The dump's DEFAULT ACL section should have re-created the stack's default
# privileges for the restoring role. If it did not (restored as a role the
# source never set defaults for), later migrations would create objects the
# PostgREST roles cannot reach: say so.
DEFACL_ROWS="$(psql -X -A -t -q -v ON_ERROR_STOP=1 "$RESTORE_DATABASE_URL" \
-c "select count(*) from pg_default_acl where defaclrole = (select oid from pg_roles where rolname = current_user) and defaclnamespace = 'public'::regnamespace")"
if [ "${DEFACL_ROWS:-0}" = "0" ]; then
echo "restore: WARNING: no default privileges for the restoring role in schema public after the restore; re-apply the stack's ALTER DEFAULT PRIVILEGES (tables, functions, sequences to anon, authenticated, service_role) before running further migrations" >&2
fi
fi
if [ "$HAVE_STORAGE" = 1 ] && [ -n "${RESTORE_STORAGE_DIR:-}" ]; then
mkdir -p "$RESTORE_STORAGE_DIR"
tar -C "$RESTORE_STORAGE_DIR" -xzf "${WORK}/${NAME}.storage.tar.gz"
echo "restore: storage unpacked into ${RESTORE_STORAGE_DIR} (restart storage-api)"
elif [ "$HAVE_STORAGE" = 1 ]; then
echo "restore: storage archive present but RESTORE_STORAGE_DIR unset; skipped"
fi
echo "restore: done ${NAME}"