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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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";
|
||||
Executable
+199
@@ -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}"
|
||||
Executable
+258
@@ -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}"
|
||||
Reference in New Issue
Block a user