Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ docker run -d --name backupdrill --restart unless-stopped \
- Arguments go straight to the CLI: `docker run --rm -e … ghcr.io/backupdrill/backupdrill estimate` or `… --help`. To start a backup now in a running scheduled container: `docker exec backupdrill backupdrill backup`.
- `BACKUPDRILL_CRON` takes a standard 5-field expression or a shortcut such as `@daily`. Anything else stops the container at start (exit code 64) instead of leaving it idle.
- `drill` needs Docker to start its throwaway Postgres, and the image doesn't get access to your Docker daemon, so run drills where Docker is available (your machine, or the GitHub Action with `drill: "true"`).
- Tags: `latest`, the CLI version (e.g. `2.10.0`), and `sha-<commit>`. Images are built for amd64 and arm64.
- Tags: `latest`, the CLI version (e.g. `2.10.1`), and `sha-<commit>`. Images are built for amd64 and arm64.

## Alerts with Healthchecks.io

Expand Down
5 changes: 3 additions & 2 deletions examples/reproducible-drill/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ pnpm install --frozen-lockfile && pnpm build # run.sh calls ../../dist, which
```

Requires Docker, Node ≥ 20, and `pg_dump`/`pg_restore` ≥ 17 on your `PATH` (or
point `PG_BIN` at them); `--with-login` also needs `psql` from the same client
package. It starts two fixture containers — Postgres and an S3-compatible store —
point `PG_BIN` at them); `--with-login` also needs `pg_restore` 17.6 or newer
and `psql` from the same client package, because the CLI's Auth restore refuses
older ones. It starts two fixture containers — Postgres and an S3-compatible store —
and the drill starts a third for its restore sandbox. `--with-login` then starts
two more on a Docker network of their own, once the sandbox is gone: Supabase's
Postgres image (`supabase/postgres`, about 1.7 GB to pull) and GoTrue, the
Expand Down
13 changes: 13 additions & 0 deletions examples/reproducible-drill/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ if [ "$WITH_LOGIN" = 1 ]; then
*) PSQL=psql ;;
esac
command -v "$PSQL" >/dev/null 2>&1 || { echo "--with-login needs psql ($PSQL), which the CLI uses for the restore" >&2; exit 1; }
# CLI 的 Auth 恢复(auth-restore.ts 的 psqlProblem)拒绝不带 \restrict 的 pg_restore(17 要 17.6 起)
# 和比 pg_restore 旧的 psql。在这里先拦,不然要等备份和演练都跑完才在恢复那一步失败
version_of() { "$1" --version | sed -E 's/.* ([0-9]+)\.([0-9]+).*/\1 \2/'; }
read -r restore_major restore_minor <<< "$(version_of "$PG_RESTORE")"
read -r psql_major psql_minor <<< "$(version_of "$PSQL")"
if [ "$restore_major" -eq 17 ] && [ "$restore_minor" -lt 6 ]; then
echo "--with-login needs pg_restore 17.6 or newer (found $restore_major.$restore_minor); the CLI's Auth restore refuses older ones" >&2
exit 1
fi
if [ "$psql_major" -lt "$restore_major" ] || { [ "$psql_major" -eq "$restore_major" ] && [ "$psql_minor" -lt "$restore_minor" ]; }; then
echo "psql $psql_major.$psql_minor is older than pg_restore $restore_major.$restore_minor; --with-login needs psql from the same client package" >&2
exit 1
fi
fi

RUN_ID="$$"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "backupdrill",
"version": "2.10.0",
"version": "2.10.1",
"description": "Open-source CLI to back up a Supabase project — Postgres database and Storage files — to your own S3/R2/B2 bucket, with a checksummed manifest.",
"type": "module",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion plugin/.tessl-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "backupdrill/supabase-backup",
"version": "1.0.0",
"version": "1.0.1",
"description": "Back up a Supabase project to your own S3, R2 or B2 bucket with the open-source backupdrill CLI, run a restore drill to prove the snapshot restores, and restore into a new project. Includes plain pg_dump and pg_restore notes for Supabase and what the Free plan does and doesn't back up.",
"private": false,
"skills": "./skills/"
Expand Down
22 changes: 13 additions & 9 deletions plugin/skills/supabase-backup/references/postgres-backup-basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Read this when the user asks what Supabase already backs up for them, wants a pl
## What Supabase keeps for you

- **Free plan:** no access to automatic backups. Supabase's docs tell Free projects to export their data themselves (`supabase db dump`) and keep off-site copies. Free projects are paused after a week of inactivity; the pause snapshot lets the user restore the paused project from the dashboard for up to a year.
- **Pro and Team:** daily backups, kept 7 days on Pro and 14 on Team, restorable from the dashboard. On the physical backup process that current projects use they can't be downloaded. Point-in-time recovery is a paid add-on: about $100/month per project for 7 days of history, and it needs a Small compute add-on or larger.
- **Pro and Team:** daily backups, kept 7 days on Pro and 14 on Team, restorable from the dashboard. On the physical backup process that current projects use they can't be downloaded. Point-in-time recovery is a paid add-on: about $100/month per project for 7 days of history.
- **Storage files are not in any database backup.** A database restore brings back the `storage.objects` rows, which only point at files. The files themselves need a separate copy (the CLI copies them once the Storage S3 keys are set).

Every backup taken from outside the project counts as egress on the user's Supabase bill. Free includes 5 GB a month, shared with whatever the app already serves. Run `backupdrill estimate --plan free` for an upper-bound estimate of the project's backup egress. As a rough guide, if each run transfers 500 MB (the Free database limit), weekly backups use about 2 GB a month and daily ones about 15 GB, three times the allowance.
Expand Down Expand Up @@ -46,11 +46,13 @@ export BACKUP_FILE="backup-$(date +%Y%m%dT%H%M%S).dump"

## Test the restore by hand

A dump that has never been restored is a guess. Restore it into a throwaway Postgres and look at the data. Start the container and wait until it accepts connections:
A dump that has never been restored is a guess. Restore it into a throwaway Postgres and look at the data. The container will hold a copy of production data, and every account on the machine can reach a port on `127.0.0.1`, so give it a new random password. Start the container and wait until it accepts connections:

```sh
# docker run can print an ID and still fail (port taken), so only a zero exit counts
cid="$(docker run -d --rm -e POSTGRES_PASSWORD=drill -p 127.0.0.1:55432:5432 postgres:17-alpine)" || cid=""
TEST_PW="$(openssl rand -hex 16)"
# docker run can print an ID and still fail (port taken), so only a zero exit counts.
# -e with no value makes docker read the password from its own environment, not from its arguments.
cid="$(POSTGRES_PASSWORD="$TEST_PW" docker run -d --rm -e POSTGRES_PASSWORD -p 127.0.0.1:55432:5432 postgres:17-alpine)" || cid=""
[ -n "$cid" ] \
&& for i in $(seq 60); do docker exec "$cid" pg_isready -q -h 127.0.0.1 -U postgres && break; sleep 1; done \
&& docker exec "$cid" pg_isready -h 127.0.0.1 -U postgres
Expand All @@ -60,23 +62,25 @@ echo "container: ${cid:-not started}"
If it prints `container: not started` (port 55432 already taken, for example) or the last `pg_isready` says `no response`, stop and fix that first. Then restore and check:

```sh
export TEST_DB="postgresql://postgres:drill@127.0.0.1:55432/postgres"
export TEST_DB="postgresql://postgres@127.0.0.1:55432/postgres"
# PGPASSWORD goes on each command, not exported: exported, it would also replace
# ~/.pgpass for the production connection in this shell.

# The dump creates schema public itself, so drop the empty one first. Then add stand-ins
# for what a Supabase project provides, so policies that mention them restore.
# --no-privileges skips GRANT and REVOKE: this test checks the data, not who can read it.
psql "$TEST_DB" --set ON_ERROR_STOP=1 -c "
PGPASSWORD="$TEST_PW" psql "$TEST_DB" --set ON_ERROR_STOP=1 -c "
drop schema public;
create role anon; create role authenticated; create role service_role;
create schema auth;
create function auth.uid() returns uuid language sql stable as 'select null::uuid';
create function auth.role() returns text language sql stable as 'select null::text';
create function auth.email() returns text language sql stable as 'select null::text';
create function auth.jwt() returns jsonb language sql stable as 'select null::jsonb';" \
&& pg_restore --no-owner --no-privileges --dbname "$TEST_DB" "$BACKUP_FILE"
&& PGPASSWORD="$TEST_PW" pg_restore --no-owner --no-privileges --dbname "$TEST_DB" "$BACKUP_FILE"

psql "$TEST_DB" -c "select count(*) from pg_tables where schemaname = 'public';"
psql "$TEST_DB" -c "select count(*) from public.<a table that must never be empty>;"
PGPASSWORD="$TEST_PW" psql "$TEST_DB" -c "select count(*) from pg_tables where schemaname = 'public';"
PGPASSWORD="$TEST_PW" psql "$TEST_DB" -c "select count(*) from public.<a table that must never be empty>;"
```

When you're done, remove only the container you started: `docker rm -f "$cid"`.
Expand Down
46 changes: 27 additions & 19 deletions src/drill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,25 +252,33 @@ async function startEphemeralPostgres(image: string, major: number, resourceName
await ensureImage(image);
// 名字先定好:Ctrl+C 可能落在 docker run 还没返回 ID 的时候,按名字才删得掉
const name = resourceName ?? `backupdrill-drill-${randomBytes(6).toString("hex")}`;
// 沙箱里是恢复出来的生产数据。端口只绑 127.0.0.1,但同机的其他系统账号照样连得上,
// 固定密码等于没设:每个沙箱一个随机密码。只写 "-e POSTGRES_PASSWORD" 不带值,
// docker 从它自己的环境变量取值,密码就不进 argv(ps 看得到 argv)
const password = randomBytes(16).toString("hex");
throwIfInterrupted();
const started = execFileAsync("docker", [
"run",
"-d",
"--rm",
"--name",
name,
...(resourceName ? ["--mount", `type=volume,source=${resourceName},target=${dataVolumeTarget}`] : []),
"-e",
"POSTGRES_PASSWORD=drill",
"-e",
"POSTGRES_DB=postgres",
"-p",
"127.0.0.1:0:5432", // 随机主机端口,避免撞端口
`--shm-size=${tuning.shmSize}`,
image,
"postgres",
...tuning.settings.flatMap((kv) => ["-c", kv]),
]);
const started = execFileAsync(
"docker",
[
"run",
"-d",
"--rm",
"--name",
name,
...(resourceName ? ["--mount", `type=volume,source=${resourceName},target=${dataVolumeTarget}`] : []),
"-e",
"POSTGRES_PASSWORD",
"-e",
"POSTGRES_DB=postgres",
"-p",
"127.0.0.1:0:5432", // 随机主机端口,避免撞端口
`--shm-size=${tuning.shmSize}`,
image,
"postgres",
...tuning.settings.flatMap((kv) => ["-c", kv]),
],
{ env: { ...process.env, POSTGRES_PASSWORD: password } }
);
// 删容器只有一份:正常收尾(drillDump 的 finally)和中断收尾(interrupt.ts)等的是同一个 promise。
// 两边各发一次 docker rm 会撞上 "removal already in progress",后到的那个报删除失败,
// 进程又在真正删完之前就退出了(信号测试抓到的竞争)。
Expand Down Expand Up @@ -343,7 +351,7 @@ async function startEphemeralPostgres(image: string, major: number, resourceName
]);
// 形如 "127.0.0.1:54321"
const port = portOut.stdout.trim().split(":").pop()!.trim();
const connString = `postgresql://postgres:drill@127.0.0.1:${port}/postgres`;
const connString = `postgresql://postgres:${password}@127.0.0.1:${port}/postgres`;

// 等就绪(容器内 pg_isready),最多 ~30s。
// 必须 -h 127.0.0.1 强制走 TCP:官方镜像 initdb 阶段先起一个只听 unix socket 的临时
Expand Down
11 changes: 11 additions & 0 deletions test/drill.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createHash, randomUUID } from "node:crypto";
import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import pg from "pg";
import {
drillDump,
classifyPostDataErrors,
Expand Down Expand Up @@ -237,6 +238,16 @@ test(
"inspect", "-f", "{{.State.Running}}", kept.keptSandbox.containerId,
]);
assert.equal(running.trim(), "true", "kept sandbox container must still be running");
// 沙箱密码每次随机(同机其他账号能连 127.0.0.1),连接串里的密码要真能登录,旧的固定密码不行
const sandboxUrl = new URL(kept.keptSandbox.connString);
assert.match(sandboxUrl.password, /^[0-9a-f]{32}$/, "sandbox password must be random");
const client = new pg.Client({ connectionString: kept.keptSandbox.connString });
await client.connect();
await client.end();
sandboxUrl.password = "drill";
const stale = new pg.Client({ connectionString: sandboxUrl.toString() });
await assert.rejects(stale.connect(), /password authentication failed/);
await stale.end().catch(() => {});
} finally {
// 断言失败也不能把容器留在机器上(xreview:测试自身不许泄漏沙箱)
await x("docker", ["rm", "-f", "-v", kept.keptSandbox.containerId]).catch(() => {});
Expand Down
Loading