2025-12-26 18:13:26 +01:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import os
|
fix(backup,restore): harden the branch fixes and prove them with tests
Backup: a container that vanishes between the docker ps listing and the
swarm-task inspect (--rm one-shots, task-history GC) no longer aborts the
whole backup run; it counts as not stoppable and is skipped.
Restore: the postgres replay streams the dump through a spooled temp file
instead of buffering it three times in memory (multi-GB dumps OOMed the
restore mid-replay), and the superuser-only line filter is COPY-aware: data
rows inside COPY ... FROM stdin blocks pass through untouched, so a row
that happens to start with COMMENT ON EXTENSION or ALTER DEFAULT PRIVILEGES
is no longer silently dropped.
The e2e runner talks to the DinD daemon through docker exec instead of a
host-published tcp://127.0.0.1:2375: port publishing is unreachable from
sandboxed runners and from hosts with broken loopback publishing, and the
unencrypted root API port disappears from the host. The debug tmp dump
shrinks to tar plus docker cp against the DinD container itself.
New coverage: an e2e reproducing the swarm flake end to end (service task
on the volume, nothing whitelisted: the backup must succeed, the very same
task container must keep running, and the service must never replace a
task), unit tests for the COPY-aware filter, the swarm-task probe including
the vanished-container path, filter_stoppable ordering, and the one-session
FOREIGN_KEY_CHECKS drop assembly. Full suite: 35 unit, 9 integration,
30 e2e green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:18:40 +02:00
|
|
|
import tempfile
|
|
|
|
|
from collections.abc import Iterable, Iterator
|
2025-12-26 18:13:26 +01:00
|
|
|
|
|
|
|
|
from ..run import docker_exec
|
|
|
|
|
|
fix(backup,restore): harden the branch fixes and prove them with tests
Backup: a container that vanishes between the docker ps listing and the
swarm-task inspect (--rm one-shots, task-history GC) no longer aborts the
whole backup run; it counts as not stoppable and is skipped.
Restore: the postgres replay streams the dump through a spooled temp file
instead of buffering it three times in memory (multi-GB dumps OOMed the
restore mid-replay), and the superuser-only line filter is COPY-aware: data
rows inside COPY ... FROM stdin blocks pass through untouched, so a row
that happens to start with COMMENT ON EXTENSION or ALTER DEFAULT PRIVILEGES
is no longer silently dropped.
The e2e runner talks to the DinD daemon through docker exec instead of a
host-published tcp://127.0.0.1:2375: port publishing is unreachable from
sandboxed runners and from hosts with broken loopback publishing, and the
unencrypted root API port disappears from the host. The debug tmp dump
shrinks to tar plus docker cp against the DinD container itself.
New coverage: an e2e reproducing the swarm flake end to end (service task
on the volume, nothing whitelisted: the backup must succeed, the very same
task container must keep running, and the service must never replace a
task), unit tests for the COPY-aware filter, the swarm-task probe including
the vanished-container path, filter_stoppable ordering, and the one-session
FOREIGN_KEY_CHECKS drop assembly. Full suite: 35 unit, 9 integration,
30 e2e green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:18:40 +02:00
|
|
|
_SUPERUSER_ONLY_PREFIXES = (b"COMMENT ON EXTENSION", b"ALTER DEFAULT PRIVILEGES")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def filter_superuser_only_lines(lines: Iterable[bytes]) -> Iterator[bytes]:
|
|
|
|
|
"""Drop superuser-only statements an app-level psql replay cannot run.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
lines: dump lines including their trailing newlines.
|
|
|
|
|
|
|
|
|
|
Yields:
|
|
|
|
|
Every line except top-level statements starting with a superuser-only
|
|
|
|
|
prefix. Lines inside COPY ... FROM stdin data blocks are passed
|
|
|
|
|
through untouched: a data row may legally start with the same bytes,
|
|
|
|
|
and dropping it would silently corrupt the restored table.
|
|
|
|
|
"""
|
|
|
|
|
in_copy = False
|
|
|
|
|
for line in lines:
|
|
|
|
|
if in_copy:
|
|
|
|
|
yield line
|
|
|
|
|
if line.rstrip(b"\r\n") == b"\\.":
|
|
|
|
|
in_copy = False
|
|
|
|
|
continue
|
|
|
|
|
if line.startswith(b"COPY ") and line.rstrip(b"\r\n").endswith(b"FROM stdin;"):
|
|
|
|
|
in_copy = True
|
|
|
|
|
yield line
|
|
|
|
|
continue
|
|
|
|
|
if line.startswith(_SUPERUSER_ONLY_PREFIXES):
|
|
|
|
|
continue
|
|
|
|
|
yield line
|
|
|
|
|
|
2025-12-26 18:13:26 +01:00
|
|
|
|
|
|
|
|
def restore_postgres_sql(
|
|
|
|
|
*,
|
|
|
|
|
container: str,
|
|
|
|
|
db_name: str,
|
|
|
|
|
user: str,
|
|
|
|
|
password: str,
|
|
|
|
|
sql_path: str,
|
|
|
|
|
empty: bool,
|
|
|
|
|
) -> None:
|
|
|
|
|
if not os.path.isfile(sql_path):
|
|
|
|
|
raise FileNotFoundError(sql_path)
|
|
|
|
|
|
|
|
|
|
# Make password available INSIDE the container for psql.
|
|
|
|
|
docker_env = {"PGPASSWORD": password}
|
|
|
|
|
|
|
|
|
|
if empty:
|
2026-07-11 08:23:11 +02:00
|
|
|
# Owner-filtered: extension members (pg_trgm's set_limit) are superuser-owned; IF EXISTS absorbs CASCADE fallout.
|
2025-12-26 18:13:26 +01:00
|
|
|
drop_sql = r"""
|
|
|
|
|
DO $$ DECLARE r RECORD;
|
|
|
|
|
BEGIN
|
|
|
|
|
FOR r IN (
|
2026-07-11 08:23:11 +02:00
|
|
|
SELECT c.relname AS name,
|
|
|
|
|
CASE c.relkind
|
|
|
|
|
WHEN 'v' THEN 'VIEW'
|
|
|
|
|
WHEN 'm' THEN 'MATERIALIZED VIEW'
|
|
|
|
|
WHEN 'f' THEN 'FOREIGN TABLE'
|
|
|
|
|
ELSE 'TABLE'
|
|
|
|
|
END AS type
|
|
|
|
|
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
|
|
|
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
|
|
|
|
|
AND pg_get_userbyid(c.relowner) = current_user
|
2025-12-26 18:13:26 +01:00
|
|
|
UNION ALL
|
2026-07-11 08:23:11 +02:00
|
|
|
SELECT p.proname AS name,
|
|
|
|
|
CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS type
|
|
|
|
|
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
|
|
|
WHERE n.nspname = 'public' AND p.prokind IN ('f', 'p', 'w')
|
|
|
|
|
AND pg_get_userbyid(p.proowner) = current_user
|
2025-12-26 18:13:26 +01:00
|
|
|
UNION ALL
|
2026-07-11 08:23:11 +02:00
|
|
|
SELECT c.relname AS name, 'SEQUENCE' AS type
|
|
|
|
|
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
|
|
|
WHERE n.nspname = 'public' AND c.relkind = 'S'
|
|
|
|
|
AND pg_get_userbyid(c.relowner) = current_user
|
|
|
|
|
UNION ALL
|
|
|
|
|
SELECT t.typname AS name, 'TYPE' AS type
|
|
|
|
|
FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
|
|
|
WHERE n.nspname = 'public'
|
|
|
|
|
AND pg_get_userbyid(t.typowner) = current_user
|
|
|
|
|
AND (t.typtype IN ('e', 'd')
|
|
|
|
|
OR (t.typtype = 'c' AND EXISTS (
|
|
|
|
|
SELECT 1 FROM pg_class c2
|
|
|
|
|
WHERE c2.oid = t.typrelid AND c2.relkind = 'c')))
|
2025-12-26 18:13:26 +01:00
|
|
|
) LOOP
|
2026-07-11 08:23:11 +02:00
|
|
|
EXECUTE format('DROP %s IF EXISTS public.%I CASCADE', r.type, r.name);
|
2025-12-26 18:13:26 +01:00
|
|
|
END LOOP;
|
|
|
|
|
END $$;
|
|
|
|
|
"""
|
|
|
|
|
docker_exec(
|
|
|
|
|
container,
|
|
|
|
|
["psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", db_name],
|
|
|
|
|
stdin=drop_sql.encode(),
|
|
|
|
|
docker_env=docker_env,
|
|
|
|
|
)
|
|
|
|
|
|
fix(backup,restore): harden the branch fixes and prove them with tests
Backup: a container that vanishes between the docker ps listing and the
swarm-task inspect (--rm one-shots, task-history GC) no longer aborts the
whole backup run; it counts as not stoppable and is skipped.
Restore: the postgres replay streams the dump through a spooled temp file
instead of buffering it three times in memory (multi-GB dumps OOMed the
restore mid-replay), and the superuser-only line filter is COPY-aware: data
rows inside COPY ... FROM stdin blocks pass through untouched, so a row
that happens to start with COMMENT ON EXTENSION or ALTER DEFAULT PRIVILEGES
is no longer silently dropped.
The e2e runner talks to the DinD daemon through docker exec instead of a
host-published tcp://127.0.0.1:2375: port publishing is unreachable from
sandboxed runners and from hosts with broken loopback publishing, and the
unencrypted root API port disappears from the host. The debug tmp dump
shrinks to tar plus docker cp against the DinD container itself.
New coverage: an e2e reproducing the swarm flake end to end (service task
on the volume, nothing whitelisted: the backup must succeed, the very same
task container must keep running, and the service must never replace a
task), unit tests for the COPY-aware filter, the swarm-task probe including
the vanished-container path, filter_stoppable ordering, and the one-session
FOREIGN_KEY_CHECKS drop assembly. Full suite: 35 unit, 9 integration,
30 e2e green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:18:40 +02:00
|
|
|
# Filter into a spooled temp file instead of building the whole dump in
|
|
|
|
|
# memory: production dumps reach many GB and the previous read/splitlines/
|
|
|
|
|
# join needed roughly three times the dump size in RSS.
|
|
|
|
|
with open(sql_path, "rb") as src, tempfile.TemporaryFile() as filtered:
|
|
|
|
|
for line in filter_superuser_only_lines(src):
|
|
|
|
|
filtered.write(line)
|
|
|
|
|
filtered.seek(0)
|
|
|
|
|
docker_exec(
|
|
|
|
|
container,
|
|
|
|
|
["psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", db_name],
|
|
|
|
|
stdin=filtered,
|
|
|
|
|
docker_env=docker_env,
|
|
|
|
|
)
|
2025-12-26 18:13:26 +01:00
|
|
|
|
|
|
|
|
print(f"PostgreSQL restore complete for db '{db_name}'.")
|