fix(backup,restore): make restore drills replayable and leave swarm tasks alone

Restore fixes, both hit by the infinito svc-bkp e2e drill:

- mariadb --empty dropped tables one docker exec at a time with SET
  FOREIGN_KEY_CHECKS=0 issued in a separate client session, so the
  session-scoped toggle never applied and any FK-referenced parent table
  (mailu.users) died with ERROR 1451. Issue the toggle and all DROPs in
  one session.
- postgres --empty now drops only current_user-owned objects (extension
  members like pg_trgm's set_limit are superuser-owned) with IF EXISTS
  absorbing CASCADE fallout, and the replay skips superuser-only dump
  lines (COMMENT ON EXTENSION, ALTER DEFAULT PRIVILEGES) that abort an
  app-user psql run under ON_ERROR_STOP.

Backup fixes:

- pg_dump now runs with --no-owner --no-privileges so future dumps are
  replayable by the owning app user in the first place.
- Swarm task containers are never stopped or started manually: the
  orchestrator replaces a stopped task and a later docker start fails on
  the detached overlay network. filter_stoppable skips them visibly and
  the whitelist stop check ignores them.

Validated end to end against a live infinito compose stack: the full
svc-bkp-volume-2-local drill (verify, restore cycle, sql replay for
mailu, keycloak and one more db) passes with these patches applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 08:23:11 +02:00
parent 57ea4592c1
commit e9030e8443
7 changed files with 129 additions and 56 deletions

View File

@@ -16,8 +16,10 @@ from .docker import (
change_containers_status,
containers_using_volume,
docker_volume_names,
filter_stoppable,
get_image_info,
has_image,
is_swarm_task,
)
from .shell import execute_shell_command
from .volume import backup_volume
@@ -66,9 +68,13 @@ def volume_is_fully_ignored(
def requires_stop(containers: list[str], images_no_stop_required: list[str]) -> bool:
"""
Stop is required if ANY container image is NOT in the whitelist patterns.
Stop is required if ANY stoppable container image is NOT in the
whitelist patterns. Swarm task containers never count: baudolo must
not cycle them (see docker.is_swarm_task).
"""
for c in containers:
if is_swarm_task(c):
continue
img = get_image_info(c)
if not any(pat in img for pat in images_no_stop_required):
return True
@@ -219,20 +225,22 @@ def main() -> int:
if args.everything:
# "everything": always do pre-rsync, then stop + rsync again
stoppable = filter_stoppable(containers)
backup_volume(versions_dir, volume_name, vol_dir)
change_containers_status(containers, "stop")
change_containers_status(stoppable, "stop")
backup_volume(versions_dir, volume_name, vol_dir)
if not args.shutdown:
change_containers_status(containers, "start")
change_containers_status(stoppable, "start")
continue
# default: rsync, and if needed stop + rsync
backup_volume(versions_dir, volume_name, vol_dir)
if requires_stop(containers, args.images_no_stop_required):
change_containers_status(containers, "stop")
stoppable = filter_stoppable(containers)
change_containers_status(stoppable, "stop")
backup_volume(versions_dir, volume_name, vol_dir)
if not args.shutdown:
change_containers_status(containers, "start")
change_containers_status(stoppable, "start")
# Stamp the backup version directory using dirval (python lib)
stamp_directory(version_dir)

View File

@@ -129,7 +129,8 @@ def backup_database(
try:
cmd = (
f"PGPASSWORD={password} docker exec -i {container} "
f"pg_dump -U {user} -d {db_name} -h localhost"
f"pg_dump -U {user} -d {db_name} -h localhost "
f"--no-owner --no-privileges"
)
_atomic_write_cmd(cmd, dump_file)
produced = True

View File

@@ -24,6 +24,31 @@ def containers_using_volume(volume_name: str) -> list[str]:
)
def is_swarm_task(container: str) -> bool:
"""Swarm-managed task containers must never be stopped or started
manually: the orchestrator replaces the stopped task and a later
`docker start` fails on the detached overlay network."""
out = execute_shell_command(
"docker inspect --format "
f"'{{{{index .Config.Labels \"com.docker.swarm.task.id\"}}}}' {container}"
)
return bool(out and out[0].strip())
def filter_stoppable(containers: list[str]) -> list[str]:
"""Containers baudolo may stop/start itself (everything but swarm tasks)."""
stoppable = []
for container in containers:
if is_swarm_task(container):
print(
f"Skipping stop/start for swarm task container '{container}'.",
flush=True,
)
continue
stoppable.append(container)
return stoppable
def change_containers_status(containers: list[str], status: str) -> None:
"""Stop or start a list of containers."""
if not containers: