128 Commits

Author SHA1 Message Date
c2f1cb8e8c Release version 3.2.2 2026-07-31 19:20:49 +02:00
eeaa838d02 fix(backup): carve the btrfs snapshot inside its subject, not beside it
The kernel refuses a snapshot whose destination sits on another
filesystem. Placing it at <parent>/.baudolo-<tag> hit that on the most
sensible layout there is: a dedicated disk mounted straight onto
/var/lib/docker, where the parent directory belongs to a different
filesystem and every run failed with EXDEV.

Placing it at <subject>/.baudolo-<tag> makes source and destination the
same filesystem by construction, so the failure cannot occur on any
layout. It also aligns the two backends: the zfs path already resolves
its snapshot inside the subject, at <subject>/.zfs/snapshot/<tag>.

btrfs does not include nested subvolumes in a snapshot, so a leftover
from an interrupted run appears in the next snapshot as an empty
directory rather than recursing, and the copy only ever reads the volume
tree below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:19:42 +02:00
4e2b3641f9 Release version 3.2.1 2026-07-31 16:25:22 +02:00
b10d50efbe fix(backup): make snapshot backups restorable and non-fatal to teardown
Four defects in the snapshot mode 3.2.0 introduced.

The resolver dropped the trailing separator get_storage_path puts on a
volume path, because os.path.abspath strips it. rsync reads "dir" as
"copy the directory" where "dir/" means "copy its contents", so every
snapshot generation landed at <volume>/files/_data/... while the live
path lands at <volume>/files/... . Restores read the live layout, and
--link-dest found nothing to match against the previous generation. The
e2e never caught it because its driver appended the separator by hand.

Snapshot teardown was fatal and masking. A busy `btrfs subvolume delete`
raised out of the finally, which skipped the generation stamp and the
compose handling on a run whose data was already complete, and replaced
whatever the body had raised. The leftover is reported instead; removing
it is a cleanup problem, not a reason to discard a good generation.

A volume created after the snapshot was taken aborted the whole run: the
volume list is enumerated inside the snapshot context, and nothing is
stopped in snapshot mode, so the host keeps creating volumes for the
duration of the copy. Such a volume is now copied live with a warning,
which is exactly what the pre-snapshot code did for it.

The snapshot pass compares by content again. 3.2.0 dropped --checksum
because a snapshot source cannot move, which is true, but the comparison
that matters is against --link-dest: a file that changed while keeping
its size and whole-second mtime was hard-linked stale out of the previous
generation, and the single snapshot pass had no authoritative pass to
repair it the way the live path does. It is still one pass against two.

--hard-restart-projects is refused alongside --snapshot, the same way
--shutdown already is: the flag exists for stacks whose database cannot
be backed up hot, which is what a snapshot removes.

Tests: the trailing separator, both teardown behaviours, the new refusal,
and app.main driving the snapshot branch - the caller that runs in
production, which no test had exercised and where the layout defect
therefore stayed invisible. The e2e driver now feeds the resolver the
string shape get_storage_path really produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:23:25 +02:00
7e815bfcf7 Release version 3.2.0 2026-07-31 13:12:38 +02:00
d4317827bd feat(backup): capture volumes from a filesystem snapshot
Backing up a live volume with rsync copies a moving target: a database
written to mid-copy lands on disk in a state no engine ever committed.
Stopping the container avoids that at the cost of downtime.

A snapshot removes both. `--snapshot {btrfs,zfs}` with `--snapshot-subject`
freezes the docker root once per run, and every volume copy is then read
from that frozen tree while the containers keep serving. A restore of such
a copy is an ordinary crash recovery, which every supported engine performs
on its own at startup.

An unsupported filesystem or an unknown snapshot kind fails loudly rather
than degrading to a live copy, since a silent fallback would return exactly
the torn backup the mode exists to prevent. `--shutdown` is rejected
alongside `--snapshot` instead of being ignored: under a snapshot no
container is ever stopped, so accepting the flag would promise downtime
semantics the run does not deliver.

Copies out of a snapshot skip rsync's --checksum verification. The source
is immutable for the lifetime of the copy, so size-and-mtime cannot race,
and dropping the second full read roughly halves the I/O per volume.

backup/app.py grew past what one module could carry and is split into
layout, policy and dumps along the lines it already had internally.

Tests: unit coverage for the new snapshot, layout, policy, volume and cli
units; e2e cases drive real btrfs, zfs and ext4 filesystems on loop devices
in a privileged container, including a MariaDB that is written to across
the snapshot and must recover from the restored copy without losing a
committed row. CI installs zfs and sets E2E_REQUIRE_FILESYSTEMS so a
missing kernel module fails the build instead of silently skipping a
filesystem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:01:43 +02:00
ec3d1a5046 Release version 3.1.4 2026-07-31 11:40:03 +02:00
37b735cf7b fix(backup): verify the post-stop volume pass by content
Each volume is copied twice into the same destination: once hot with the
container running, once cold after it is stopped. The cold pass is meant to
correct the hot one, but it uses rsync's quick check, which compares size and
whole-second mtime.

A pre-allocated 16 MiB WAL segment never changes size. When its last pre-stop
write and postgres' shutdown checkpoint fall in the same whole second, the cold
pass skips it while still replacing global/pg_control, whose previous write was
seconds earlier. The generation then holds a post-shutdown pg_control pointing at
a checkpoint LSN whose WAL bytes are still zero. Restoring it gives

    invalid record length at 0/3CB7A20: expected at least 24, got 0
    invalid checkpoint record
    PANIC: could not locate a valid checkpoint record at 0/3CB7A20

and postgres crash-loops until the restore test gives up after 1200s. Observed in
infinito-nexus-core CI run 30586213932, debian leg; the centos leg of the same
run restored cleanly, and the two generations differ by exactly one 16 MiB WAL
segment.

authoritative is a required keyword rather than an optional flag, so each of the
four call sites states which pass it is. Set, it adds --checksum, so the cold
pass compares content. The hot passes keep the quick check.

Reproduced with real rsync in both directions: without --checksum the WAL stays
zero while pg_control is post-shutdown; with it the pair is consistent. Hardlink
dedup against --link-dest is unaffected, measured at 20/20 with generation size
unchanged.

Rejected alternatives, both measured rather than argued: --ignore-times destroys
dedup (20/20 to 0/20 hardlinks, generation 36 to 72 MiB), and --modify-window=-1
makes correctness depend on the destination filesystem preserving sub-second
mtimes, which silently degrades on NFS or ext3.

COST. The quick check scales with file count; --checksum scales with bytes, read
on both sides, and it runs while the container is stopped. Measured warm-cache on
215 MiB: 0.10s to 0.39s, a factor of 3.9. Derived for disk-bound runs, the extra
stop time stays under a minute up to roughly 4 GB on spinning disk, 15 GB on a
SATA SSD, 60 GB on NVMe. At 1 TB it is 17 minutes on NVMe and over three hours on
spinning disk; at 10 TB it is hours to days. No size threshold is built in - a
guessed one would drop the guarantee exactly where an unrestorable backup costs
most. Volumes at that scale need filesystem snapshots or pg_basebackup rather
than two rsync passes over a live tree, which is a limit of this approach and not
of this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:32:37 +02:00
9dc57c3235 Release version 3.1.3 2026-07-20 20:01:26 +02:00
8409843ff9 fix(restore): wrap the postgres dump replay in a single transaction
The dump replay ran statement-by-statement with autocommit. When restore --empty runs against a LIVE database, the pre-clean drops a table, the replay recreates it and autocommits, and a concurrent writer (discourse's mini_scheduler upserting scheduler_stats(id=1)) inserts the same primary key into the empty table before the dump's COPY loads it. The COPY then aborts with a duplicate-key violation under ON_ERROR_STOP and the whole restore fails. Running the replay with --single-transaction keeps the recreated table invisible to other sessions until commit, so the writer can never insert the racing row.

The --empty pre-clean stays multi-statement (\gexec, one DROP per statement): running every DROP in one transaction exhausts max_locks_per_transaction on large schemas (e.g. gitlab).

Extract the pre-clean SQL from the inline string into restore/db/empty_preclean.sql (loaded via dirname(__file__)) and declare it as package-data so it ships in the wheel. Add a unit test guarding the single-transaction/multi-statement split and an e2e that reproduces the live-writer race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:00:31 +02:00
d2ba2eb5ae Release version 3.1.2 2026-07-18 00:39:10 +02:00
6a016d7a58 chore(claude): ignore local runtime state under .claude, keep settings.json
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:38:38 +02:00
d1d5445b1d chore(claude): pause for confirmation before CHANGELOG or pyproject edits
Release metadata (version bump and changelog entry) stays a manual step; agent edits to CHANGELOG.md and pyproject.toml now require explicit operator approval. The local .mcp.json is ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:37:28 +02:00
bd267cc280 fix(restore): drop text search objects in the postgres --empty pre-clean
A schema shipping a custom text search dictionary (taiga's english_stem_nostop) survived the pre-clean and aborted the dump replay with a duplicate pg_ts_dict_dictname_index violation under ON_ERROR_STOP. The discovery SELECT now also enumerates user-owned pg_ts_config and pg_ts_dict entries. The string-assertion unit test is replaced by real scenario data in the e2e: the seeded schema now contains an overloaded f()/f(int) pair and the nostop dictionary plus configuration, and the restored database is queried to prove each survives the backup, pre-clean and replay cycle exactly once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:34:53 +02:00
53460242d8 Release version 3.1.1 2026-07-17 17:28:54 +02:00
01a00dd791 fix(restore): drop overloaded functions by identity signature in the --empty pre-clean
A signature-less DROP FUNCTION aborts with 'function name is not unique' as soon as a schema overloads a name, killing the whole --empty replay under ON_ERROR_STOP. The function branch now emits name(identity args) via pg_get_function_identity_arguments; because that compound must not be identifier-quoted as a whole, quoting moves from the outer format into each branch, guarded by a unit test that pins the per-branch %I quoting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:27:55 +02:00
779f297c85 Release version 3.1.0 2026-07-15 03:36:59 +02:00
35a4c355fe feat(cli): make --database-containers and --images-no-stop-required optional
Both default to an empty list so a pure file backup needs no dummy
arguments; an empty stop whitelist keeps the conservative stop-all
behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 03:34:53 +02:00
b0ae1aba54 build(make): run the three test suites concurrently and allow e2e subsets
make test now runs clean and build once, then unit, integration and e2e
via a -j3 sub-make over run-only targets so a second clean cannot race
the build. scripts/test-e2e.sh accepts E2E_TEST_PATTERN to run a subset
of the e2e suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 03:34:37 +02:00
57d75b1e13 fix(restore): run --empty pre-clean per statement and drop non-public schemas
One DO-block ran every DROP in a single transaction and exhausted
max_locks_per_transaction on large schemas (gitlab); emit one DROP per
row and execute via \gexec instead. Also drop user-owned non-public
schemas so a dump that CREATE SCHEMAs (discourse) does not abort on the
existing schema under ON_ERROR_STOP.

Covered by a new DinD e2e test restoring --empty against a fully
populated database with a non-public schema and every object class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 03:34:20 +02:00
bf5f6db7c3 Release version 3.0.0 2026-07-12 18:29:49 +02:00
b4d7e7f396 Merge branch 'feat/exact-image-match'
# Conflicts:
#	tests/e2e/helpers.py
#	tests/e2e/test_e2e_dump_only_fallback_to_files.py
#	tests/e2e/test_e2e_images_no_backup_required_early_skip.py
2026-07-12 18:27:05 +02:00
f9776ac47a feat(backup)!: exact --images-* matching and --hard-restart-projects
Match --images-no-stop-required and --images-no-backup-required against
the container's exact .Config.Image instead of a substring, so callers
pass full repo:tag references (registry prefix included) and near-miss
image names no longer flip the stop/skip decision. Rename the opt-in
--hard-compose-restart flag to --hard-restart-projects.

The e2e suite pins a SPOT for the DB images and in-container data dirs
(postgres:alpine at /var/lib/postgresql, mariadb:latest at
/var/lib/mysql) and passes exact image refs to the --images-* flags.

BREAKING CHANGE: --images-* now require exact image references, not
substrings; --hard-compose-restart is renamed to --hard-restart-projects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:19:20 +02:00
8c1a6cc465 refactor(restore)!: restore volume files via direct host rsync
Drop the alpine-rsync helper image: resolve the volume mountpoint with
docker volume inspect and rsync the backup straight into it. Removes the
--rsync-image flag and the E2E_RSYNC_IMAGE pre-pull; the e2e test
container now mounts /var/lib/docker rw so the direct restore can write.

BREAKING CHANGE: the restore 'files' subcommand no longer accepts
--rsync-image; rsync must be available on the host running baudolo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:18:57 +02:00
d6d4773fd9 Release version 2.0.0 2026-07-12 14:56:02 +02:00
82913291b6 test(e2e): SPOT db images, PG18 mount layout, drop removed flags
Add a single source of truth in tests/e2e/helpers.py for the database
images and their in-container data dirs (POSTGRES_IMAGE=postgres:alpine,
POSTGRES_DATA_DIR=/var/lib/postgresql, MARIADB_IMAGE=mariadb:latest,
MARIADB_DATA_DIR=/var/lib/mysql) and route every test through it.
postgres:alpine now tracks 18+, which refuses a mount at
/var/lib/postgresql/data and stores data under /var/lib/postgresql, so
the mounts and the marker path move there. Drop the --rsync-image and the
renamed hard-restart flag from the invocations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:54:16 +02:00
e5da813a9f refactor(restore)!: rsync volumes directly on the host, drop alpine-rsync
Restore now resolves the target volume's mountpoint via docker volume
inspect and rsyncs into it directly, mirroring how the backup path
already reads the mountpoint; the alpine-rsync container and the
--rsync-image flag are gone. The e2e harness mounts /var/lib/docker
read-write in the test container so the direct restore can write, the
same way baudolo runs as root on a real host.

BREAKING CHANGE: the restore 'files' subcommand no longer accepts
--rsync-image; the caller must have write access to the docker volume
root (root on the host), which is baudolo's normal privilege.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:53:55 +02:00
331931d617 feat(backup)!: opt-in hard restart and mandatory backups-dir
Rename --docker-compose-hard-restart-required to --hard-compose-restart
and change its default from ["mailu"] to [] (nargs="*"): the compose
down/up is now opt-in, so compose hosts pass "mailu" while swarm hosts,
where the dir is a stack whose overlay network collides with compose up,
pass nothing. Make --backups-dir mandatory (no /var/lib/backup/ default)
so a run can never silently target the wrong backup root.

BREAKING CHANGE: the old flag name is removed, the implicit mailu default
is gone, and --backups-dir must be passed explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:53:13 +02:00
45d3b0ad7c Release version 1.8.1 2026-07-12 02:59:17 +02:00
fe5bed8254 Merge branch 'main' of github.com:kevinveenbirkenbach/backup-docker-to-local 2026-07-12 02:58:36 +02:00
7a7ec57b54 fix(restore): drop user-owned collations in the --empty pre-clean
The drop_sql loop covered relations, routines, sequences and types but
not pg_collation, so a dump's CREATE COLLATION (OpenProject's ICU
public.versions_name) aborted the ON_ERROR_STOP replay with 'collation
already exists'. Add the fifth UNION ALL branch; DROP COLLATION IF
EXISTS public.<name> CASCADE rides the existing loop, and the loop
already drops every user table, so CASCADE fallout is absorbed by the
IF EXISTS no-ops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 02:55:29 +02:00
2bbe7d180a Merge pull request #2 from kevinveenbirkenbach/dependabot/github_actions/actions-cad83fc9bf
Bump the actions group with 4 updates
2026-07-11 14:16:28 +02:00
dependabot[bot]
286ef179da Bump the actions group with 4 updates
Bumps the actions group with 4 updates: [actions/checkout](https://github.com/actions/checkout), [actions/upload-artifact](https://github.com/actions/upload-artifact), [docker/login-action](https://github.com/docker/login-action) and [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata).


Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `dependabot/fetch-metadata` from 2 to 3
- [Release notes](https://github.com/dependabot/fetch-metadata/releases)
- [Commits](https://github.com/dependabot/fetch-metadata/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: dependabot/fetch-metadata
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 07:44:12 +00:00
dependabot[bot]
6cb0b8a548 Bump python from 3.11-slim to 3.14-slim (#1)
Bumps python from 3.11-slim to 3.14-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 07:44:09 +00:00
e1f1b602d3 Release version 1.8.0 2026-07-11 09:43:25 +02:00
b9a8b391f0 fix(backup): re-raise inspect failures for containers that still exist
Treating every failed swarm-task inspect as skippable opened a false-green
window: a transient inspect failure on a still-running, non-whitelisted
container skipped the stop and backed the volume up hot while the run
reported success. Re-check whether the container is still listed; only a
genuinely vanished container skips, an existing one re-raises so a broken
daemon keeps failing the run loudly. Covered by unit tests for both paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:32:53 +02:00
c949f2c5cf ci(deps): keep actions, docker base and pip deps updated automatically
Dependabot opens weekly PRs for the three update surfaces: github-actions
(grouped into one PR; the first one also clears the Node 20 deprecation
warning on checkout@v4 and upload-artifact@v4), the python base image, and
the pip dependencies. A companion workflow enables auto-merge for minor and
patch bumps so they land on their own once the required make test check is
green; major bumps stay manual.

Requires two repo settings: allow auto-merge, and a branch protection rule
on main with make test as required status check, which is the gate that
keeps auto-merge from landing untested changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:19:12 +02:00
96e6b3ea93 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:19:11 +02:00
79214e64e8 test(unit): mock is_swarm_task in the requires_stop tests
requires_stop now probes is_swarm_task per container, which runs a real
docker inspect; the unit CI container has no docker socket, so the three
whitelist tests died with BackupException. Mock the probe to False so
they assert the unchanged whitelist logic, and add a swarm case proving
a task container never triggers a stop and skips the image check
entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 08:35:23 +02:00
e9030e8443 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>
2026-07-11 08:23:11 +02:00
57ea4592c1 Release version 1.7.1 2026-05-26 00:52:37 +02:00
ad5d8fcda3 fix(backup): force TCP for mariadb-dump to match '<user>'@'%' grant
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:46:47 +02:00
bfa596ae30 fix(test-e2e): set DinD MTU to 1280 to fix ghcr.io pull timeouts on broken-PMTUD host paths
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:45:29 +02:00
21b4d237d3 Release version 1.7.0 2026-02-07 14:00:11 +01:00
ec051b4c2b backup: support all valid docker compose file names
Detect compose files case-insensitively and support:
- compose.yml / compose.yaml
- docker-compose.yml / docker-compose.yaml

Replace hard-coded docker-compose.yml checks with a shared
finder helper and extend unit tests accordingly.

https://chatgpt.com/share/69873720-d444-800f-99f7-f7799fc10c0b
2026-02-07 13:58:52 +01:00
ed78f69b3b Release version 1.6.0 2026-02-06 10:39:02 +01:00
a69074c302 test(e2e): replace ls with find to satisfy shellcheck SC2012 2026-02-06 10:37:31 +01:00
0b4696f649 backup(compose): drop custom compose/env detection and strictly delegate to wrapper or docker compose
https://chatgpt.com/share/6985b5da-d5fc-800f-b5e5-de22a199a0c8
2026-02-06 10:35:15 +01:00
e3f28098bd Release version 1.5.0 2026-01-31 22:06:05 +01:00
babadcb038 fix(backup,ci): make databases.csv optional and upgrade Docker CLI in image
- Handle missing or empty databases.csv gracefully with warnings and empty DataFrame
- Add unit tests for robust databases.csv loading behavior
- Adjust seed tests to assert warnings across multiple print calls
- Replace Debian docker.io with docker-ce-cli to avoid Docker API version mismatch
- Install required build tools (curl, gnupg) for Docker repo setup

https://chatgpt.com/share/697e6d9d-6458-800f-9d12-1e337509be4e
2026-01-31 22:01:12 +01:00
fbfdb8615f **Commit message:**
Fix Docker CLI install, switch test runners to bash, and stabilize unit tests for compose/seed mocks

https://chatgpt.com/share/697e68cd-d22c-800f-9b2e-47ef231b6502
2026-01-31 21:40:39 +01:00
2f5882f5c1 Release version 1.4.0 2026-01-31 18:28:29 +01:00
522391fdd3 Merge branch 'main' of github.com:kevinveenbirkenbach/backup-docker-to-local 2026-01-31 18:25:55 +01:00
b3c9cf5ce1 backup: restart compose stacks via wrapper-aware command resolution
- Prefer `compose` wrapper (if present) when restarting stacks to ensure
  identical file and env resolution as Infinito.Nexus
- Fallback to `docker compose` with explicit detection of:
  - docker-compose.yml
  - docker-compose.override.yml
  - docker-compose.ca.override.yml
  - .env / .env/env via --env-file
- Replace legacy `docker-compose` usage
- Log exact compose commands before execution
- Add unit tests covering wrapper vs fallback behavior

https://chatgpt.com/share/697e3b0c-85d4-800f-91a7-42324599a63c
2026-01-31 18:25:23 +01:00
2ed3472527 Ignored build/ 2026-01-16 10:45:09 +01:00
54737cefa7 Release version 1.3.0 2026-01-10 18:41:55 +01:00
d976640312 fix(seed): handle empty databases.csv and add unit tests
- Gracefully handle empty databases.csv by creating header columns and emitting a warning
- Add _empty_df() helper for consistent DataFrame initialization
- Add unit tests for baudolo-seed including empty-file regression case
- Apply minor formatting fixes across backup and e2e test files

https://chatgpt.com/share/69628f0b-8744-800f-b08d-2633e05167da
2026-01-10 18:40:22 +01:00
e4bc075474 Release version 1.2.0 2025-12-29 11:46:39 +01:00
f3ef86a444 feat(backup): stricter databases.csv semantics + atomic SQL dumps
- read databases.csv with stable types (dtype=str, keep_default_na=False)
- validate database field: require '*' or concrete name (no empty/NaN)
- support Postgres cluster dumps via '*' entries (pg_dumpall)
- write SQL dumps atomically to avoid partial/empty files
- early-skip fully ignored volumes before creating backup directories
- update seed CLI to enforce new contract and update by (instance,database)
- adjust tests: sql dir naming + add E2E coverage for early-skip and '*' seeding
2025-12-29 11:39:57 +01:00
c01ab55f2d test(e2e): add dump-only-sql mixed-run + CLI contract coverage
- rename dump-only flag to --dump-only-sql across docs and tests
- update backup logic: skip files/ only for DB volumes when dumps succeed; fallback to files when dumps fail
- extend e2e helpers to support dump_only_sql
- add e2e mixed-run regression test (DB dump => no files/, non-DB => files/)
- add e2e CLI/argparse contract test (--dump-only-sql present, --dump-only rejected)
- fix e2e files test to expect file backups for non-DB volumes in dump-only-sql mode and verify restore
- update changelog + README flag table

https://chatgpt.com/share/69522d9c-ce08-800f-9070-71df3bd779ae
2025-12-29 08:28:23 +01:00
e3cdfd6fc4 Release version 1.1.1 2025-12-28 22:52:31 +01:00
df32671cec fix(backup): fallback to file backup in dump-only mode when no DB dump is possible
- Change DB backup helpers to return whether a dump was actually produced
- Detect DB containers without successful dumps in --dump-only mode
- Fallback to file backups with a warning instead of skipping silently
- Refactor DB dump logic to return boolean status
- Add E2E test covering dump-only fallback when databases.csv entry is missing

https://chatgpt.com/share/6951a659-2b0c-800f-aafa-3e89ae1eb697
2025-12-28 22:51:12 +01:00
d563dce20f Ignored dist 2025-12-28 22:19:19 +01:00
0222f7f109 Release version 1.1.0 2025-12-28 22:16:41 +01:00
6adafe6b1f fix(backup): log missing db config instead of raising
- Use module logger in backup/db.py
- Skip db dump when no databases.csv entry is present
- Apply black/formatting cleanup across backup/restore/tests

https://chatgpt.com/share/69519d45-b0dc-800f-acb6-6fb8504e9b46
2025-12-28 22:12:31 +01:00
88b35ee923 backup(cli): use FHS-compliant default backup directory
- Replace dynamic repo name detection with stable default
- Switch default backups directory from /Backups to /var/lib/backup
- Align CLI defaults with Linux FHS best practices

https://chatgpt.com/share/69515eed-001c-800f-b1da-aee8d8683e63
2025-12-28 17:46:31 +01:00
71f79929be Changedf pi update mirror 2025-12-27 12:49:24 +01:00
0fb8efba4f Ignored .egg-info 2025-12-27 09:33:59 +01:00
3b39a6ef02 Release version 1.0.0 2025-12-27 09:30:38 +01:00
e0b2e8934e docs(readme): rewrite README to reflect deterministic backup design
- clarify separation between file backups (always) and SQL dumps (explicit only)
- document correct nested backup directory layout
- remove legacy script-based usage and outdated sections
- add explicit explanation of database definition scope
- update usage examples to current baudolo CLI

https://chatgpt.com/share/694ef6d2-7584-800f-a32b-27367f234d1d
2025-12-26 21:57:46 +01:00
bbb2dd1732 Removed .travis 2025-12-26 21:03:00 +01:00
159502af5e Added mirros 2025-12-26 20:50:29 +01:00
698d1e7a9e ci: add Makefile-driven CI with unit, integration and e2e tests
- add GitHub Actions CI workflow using Makefile targets exclusively
- run unit, integration and e2e tests via `make test`
- publish Docker image to GHCR on SemVer tags
- force-update `stable` git tag after successful release
- add integration test for seed CLI (CSV upsert behavior)
- extend Makefile with test-unit and test-integration targets

https://chatgpt.com/share/694ee54f-b814-800f-a714-e87563e538b7
2025-12-26 20:43:06 +01:00
f8420c8bea renamed configure to seed 2025-12-26 19:58:39 +01:00
8e1a53e1f9 Deleted Starting file 2025-12-26 19:47:54 +01:00
7b55d59300 fix(restore): handle bytes stdin correctly in subprocess wrapper
Avoid passing raw bytes/str via stdin to subprocess.run(), which caused
"'bytes' object has no attribute 'fileno'" and
"stdin and input arguments may not both be used" errors.

If stdin is bytes or str, pass it via input= instead; otherwise forward
stdin unchanged. This fixes Postgres restore failures in E2E tests
without changing productive restore logic.

https://chatgpt.com/share/694ed70d-9e04-800f-8dec-edf08e6e2082
2025-12-26 19:42:17 +01:00
cf6f4d8326 test(e2e): stabilize MariaDB 11 tests and fix restore empty-mode client selection
- add `make clean` and run it before `test-e2e` to avoid stale artifacts
- restore: do not hardcode `mysql` for --empty; use detected mariadb/mysql client
- e2e: improve subprocess error output for easier CI debugging
- e2e: adjust MariaDB readiness checks for socket-only root on MariaDB 11
- e2e: add `wait_for_mariadb_sql` and run SQL readiness checks for dedicated TCP user
- e2e: update MariaDB full/no-copy tests to use dedicated user over TCP and consistent credentials

https://chatgpt.com/share/694ecfb8-f8a8-800f-a1c9-a5f410d4ba02
2025-12-26 19:10:55 +01:00
4af15d9074 fix(restore): allow restoring files from different source volume
The file restore command previously assumed that the target volume name
was identical to the volume name used in the backup path. This caused
restores to fail (exit code 2) when restoring data from one volume backup
into a different target volume.

Introduce an optional --source-volume argument for `baudolo-restore files`
to explicitly specify the backup source volume while keeping the target
volume unchanged.

- Default behavior remains fully backward-compatible
- Enables restoring backups from volume A into volume B
- Fixes E2E test scenario restoring into a new volume

Tests:
- Update E2E file restore test to use --source-volume

https://chatgpt.com/share/694ec70f-1d7c-800f-b221-9d22e4b0775e
2025-12-26 18:33:58 +01:00
c30b4865d4 refactor: migrate to src/ package + add DinD-based E2E runner with debug artifacts
- Replace legacy standalone scripts with a proper src-layout Python package
  (baudolo backup/restore/configure entrypoints via pyproject.toml)
- Remove old scripts/files (backup-docker-to-local.py, recover-docker-from-local.sh,
  databases.csv.tpl, Todo.md)
- Add Dockerfile to build the project image for local/E2E usage
- Update Makefile: build image and run E2E via external runner script
- Add scripts/test-e2e.sh:
  - start DinD + dedicated network
  - recreate DinD data volume (and shared /tmp volume)
  - pre-pull helper images (alpine-rsync, alpine)
  - load local baudolo:local image into DinD
  - run unittest E2E suite inside DinD and abort on first failure
  - on failure: dump host+DinD diagnostics and archive shared /tmp into artifacts/
- Add artifacts/ debug outputs produced by failing E2E runs (logs, events, tmp archive)

https://chatgpt.com/share/694ec23f-0794-800f-9a59-8365bc80f435
2025-12-26 18:13:26 +01:00
41910aece2 Optimized entry columns 2025-10-18 09:58:06 +02:00
b6dd624f97 Added install hints to pass install requirements 2025-09-11 20:45:30 +02:00
47828c44db Use dirval CLI instead of direct Python script reference in backup-docker-to-local.py and declare dirval in requirements.yml
Details:
- Replaced python call to directory-validator.py with direct 'dirval' command
- Updated error message accordingly
- Added dirval as dependency in requirements.yml

Conversation: https://chatgpt.com/share/68c31763-6a84-800f-a697-50fa40e9841b
2025-09-11 20:40:21 +02:00
a538e537cb Added emoji 2025-07-17 00:26:22 +02:00
8f72d61300 Bypass buffer 2025-07-17 00:19:10 +02:00
c754083cec Added more detailled info to is_image_whitelisted 2025-07-17 00:13:38 +02:00
84d0fd6346 Added more detailled output why stopped 2025-07-17 00:05:44 +02:00
627187cecb Solved variable bug 2025-07-16 23:41:17 +02:00
978e153723 Changed special instances to database containers for more clarity 2025-07-16 14:50:32 +02:00
2bf2b0798e Solved bug 2025-07-16 14:47:32 +02:00
8196a0206b Optimized parameters 2025-07-16 14:43:43 +02:00
c4cbb290b3 Made IMAGES_NO_STOP_REQUIRED obligatoric 2025-07-16 10:55:21 +02:00
2d2376eac8 Implemented new parameters to make it more flexibel for cymais 2025-07-14 18:47:25 +02:00
8c4ae60a6a Left hint 2025-07-10 22:12:29 +02:00
18d6136de0 Used streaming instead of memory to prevent overflow 2025-07-03 14:37:36 +02:00
3ed89a59a8 Added failure handling for bussy databases 2025-07-03 12:09:44 +02:00
7d3f0a3ae3 Added roles, and stop on failure 2025-07-03 12:06:38 +02:00
5762754ed7 Added restore_backup.py restore_postgres_databases.py 2025-07-03 11:59:40 +02:00
556cb17433 Implemented check for empty database name 2025-04-21 10:52:06 +02:00
2e2c8131c4 Implemented fallback_pg_dumpall 2025-04-19 00:21:45 +02:00
5005d577cc Update README.md 2025-04-19 00:04:29 +02:00
327b666237 Removed setup instructions, because they are managed now by pkgmgr 2025-04-01 13:20:47 +02:00
a7c6fa861a Moved requirements from cymais to this role 2025-04-01 13:18:30 +02:00
f6c57be1b7 Added Funding 2025-03-12 20:52:47 +01:00
9d990a728d Update README.md 2025-03-12 11:14:40 +01:00
a355f34e6e Update README.md 2025-03-04 22:35:22 +01:00
f847c8dd74 Merge branch 'main' of github.com:kevinveenbirkenbach/docker-volume-backup 2024-12-03 11:19:40 +01:00
3e225b0317 Added hard restart for mailu 2024-12-03 11:19:12 +01:00
6537626d77 optimized code formating 2024-02-05 19:18:54 +01:00
da7e5cc9be changed loading of directory-validator 2024-02-05 19:16:09 +01:00
69a1ea30aa Implemented stamp function 2024-01-29 20:04:21 +01:00
e9588b0e31 Added exception catching 2024-01-14 01:46:47 +01:00
42566815c4 solved start bug 2024-01-14 01:25:54 +01:00
8bc2b068ff Added condition to stop containers 2024-01-12 18:58:02 +01:00
25d428fc9c Removed skipping of unused volumes 2024-01-12 18:42:26 +01:00
0077efa63c Implemented stop on first error 2024-01-12 16:20:17 +01:00
9d8e80f793 Implemented better parameter check 2024-01-12 15:42:51 +01:00
d2b699c271 Implemented postgres recovery 2024-01-12 11:47:46 +01:00
b7dcb17fd5 Optimized logic for central databases 2024-01-11 20:51:55 +01:00
7f6f5f6dc8 Optimized logic for central databases 2024-01-11 20:47:57 +01:00
75d48fb3e9 Removed unnecessary warning 2024-01-11 20:40:07 +01:00
bb3d20c424 Solved path comparisment bug 2024-01-11 16:21:39 +01:00
f057104a65 Solved order bug 2024-01-11 15:56:34 +01:00
7fe1886ff9 Solved trailing spaces bug 2024-01-11 12:20:38 +01:00
35e28f31d2 Changed logic so that volume is not created for db recoveries 2024-01-11 11:04:03 +01:00
15a1f17184 Changed logic so that volume is not created for db recoveries 2024-01-11 10:58:35 +01:00
ace1a70488 Optimized database recovery function 2024-01-11 03:04:13 +01:00
d537393da8 Solved array bug 2024-01-09 13:18:17 +01:00
2b716e5d90 Optimized code performance 2024-01-09 12:59:53 +01:00
90 changed files with 7220 additions and 444 deletions

3
.claude/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*
!.gitignore
!settings.json

10
.claude/settings.json Normal file
View File

@@ -0,0 +1,10 @@
{
"permissions": {
"ask": [
"Edit(CHANGELOG.md)",
"Write(CHANGELOG.md)",
"Edit(pyproject.toml)",
"Write(pyproject.toml)"
]
}
}

7
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
github: kevinveenbirkenbach
patreon: kevinveenbirkenbach
buy_me_a_coffee: kevinveenbirkenbach
custom: https://s.veen.world/paypaldonate

21
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
---
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns:
- "*"
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
- package-ecosystem: pip
directory: /
schedule:
interval: weekly

100
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,100 @@
name: CI (make tests, stable, publish)
on:
push:
branches: ["**"]
tags: ["v*.*.*"] # SemVer tags like v1.2.3
pull_request:
permissions:
contents: write # push/update 'stable' tag
packages: write # push to GHCR
env:
IMAGE_NAME: baudolo
REGISTRY: ghcr.io
IMAGE_REPO: ${{ github.repository }}
jobs:
test:
name: make test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Show docker info
run: |
docker version
docker info
- name: Provide zfs so the snapshot suite covers every filesystem
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends zfsutils-linux
sudo modprobe zfs
zpool version
- name: Run all tests via Makefile
env:
E2E_REQUIRE_FILESYSTEMS: "btrfs ext4 zfs"
run: |
make test
- name: Upload E2E artifacts (always)
if: always()
uses: actions/upload-artifact@v7
with:
name: e2e-artifacts
path: artifacts
if-no-files-found: ignore
stable_and_publish:
name: Mark stable + publish image (SemVer tags only)
needs: [test]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout (full history for tags)
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Derive version from tag
id: ver
run: |
TAG="${GITHUB_REF#refs/tags/}" # v1.2.3
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Mark 'stable' git tag (force update)
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -f stable "${GITHUB_SHA}"
git push -f origin stable
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build image (Makefile)
run: |
make build
- name: Tag image for registry
run: |
# local image built by Makefile is: baudolo:local
docker tag "${IMAGE_NAME}:local" "${REGISTRY}/${IMAGE_REPO}:${{ steps.ver.outputs.tag }}"
docker tag "${IMAGE_NAME}:local" "${REGISTRY}/${IMAGE_REPO}:stable"
docker tag "${IMAGE_NAME}:local" "${REGISTRY}/${IMAGE_REPO}:sha-${GITHUB_SHA::12}"
- name: Push image
run: |
docker push "${REGISTRY}/${IMAGE_REPO}:${{ steps.ver.outputs.tag }}"
docker push "${REGISTRY}/${IMAGE_REPO}:stable"
docker push "${REGISTRY}/${IMAGE_REPO}:sha-${GITHUB_SHA::12}"

View File

@@ -0,0 +1,29 @@
---
name: Dependabot auto-merge
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
# Enable auto-merge for minor/patch dependency bumps; majors stay manual.
# The merge only happens once every required status check (make test) is
# green, so the CI stays the gate.
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Fetch dependency metadata
id: metadata
uses: dependabot/fetch-metadata@v3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable auto-merge (minor + patch)
if: steps.metadata.outputs.update-type != 'version-update:semver-major'
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

7
.gitignore vendored
View File

@@ -1 +1,6 @@
databases.csv __pycache__
artifacts/
*.egg-info
dist/
build/
.mcp.json

View File

@@ -1,2 +0,0 @@
language: shell
script: shellcheck $(find . -type f -name '*.sh')

283
CHANGELOG.md Normal file
View File

@@ -0,0 +1,283 @@
# Changelog
## [3.2.2] - 2026-07-31
- Backup: the btrfs snapshot is carved inside its subject, as
*<data root>/.baudolo-<tag>*, not beside it. The kernel refuses a snapshot
whose destination is on another filesystem, which is exactly what the parent
directory is when the data root is a mountpoint of its own — a dedicated disk
mounted onto */var/lib/docker* failed every run with EXDEV. Placing it inside
makes source and destination the same filesystem by construction, and aligns
btrfs with the zfs path, which already resolves its snapshot inside the
subject at *<subject>/.zfs/snapshot/<tag>*. A leftover from an interrupted run
appears in the next snapshot as an empty directory rather than recursing,
since btrfs does not include nested subvolumes.
## [3.2.1] - 2026-07-31
- Backup: the snapshot resolver keeps the trailing separator *get_storage_path*
puts on a volume path — *os.path.abspath* stripped it. rsync reads *dir* as
"copy the directory" where *dir/* means "copy its contents", so every snapshot
generation landed at *<volume>/files/_data/...* while the live path lands at
*<volume>/files/...*. Restores read the live layout, and *--link-dest* had
nothing to match against the previous generation.
- Backup: snapshot teardown no longer fails a completed run. A busy
*btrfs subvolume delete* raised out of the *finally*, skipping the generation
stamp and the compose handling on a run whose data was already copied, and
masking whatever the body had raised. The leftover is reported instead.
- Backup: a volume created after the snapshot was taken is copied live with a
warning instead of aborting the run. Nothing is stopped in snapshot mode, so
the host keeps creating volumes for the whole copy.
- Backup: the snapshot pass compares by content (*--checksum*) again. 3.2.0
dropped it because a snapshot source cannot move, which is true, but the
comparison that matters is against *--link-dest*: a file that changed while
keeping its size and whole-second mtime was hard-linked stale out of the
previous generation, and the single pass had no authoritative pass to repair
it. Still one pass where the live path takes two.
- Backup: *--hard-restart-projects* is refused alongside *--snapshot*, like
*--shutdown* already is. It exists for stacks whose database cannot be backed
up hot, which is what a snapshot removes.
- Tests: the trailing separator, both teardown behaviours, the new refusal, and
*app.main* driving the snapshot branch — the caller that runs in production,
which no test had exercised, which is why the layout defect shipped.
## [3.2.0] - 2026-07-31
- Backup: *--snapshot {btrfs,zfs}* with *--snapshot-subject* captures every
volume from one atomic filesystem snapshot. The subject — the btrfs subvolume
or zfs dataset holding the docker volumes, e.g. */var/lib/docker* — is frozen
once per run, so a generation shares a single point in time and no container
is stopped. Restoring such a copy is an ordinary crash recovery, which every
supported engine performs at startup. This is the mode 3.1.4 pointed to for
volumes where two rsync passes over a live tree stop being affordable.
- Backup: snapshot passes copy once and drop *--checksum*. Verification exists
because a hot pass writes its destination from a moving source; a snapshot
source cannot move, so size and mtime cannot race and the second full read is
pure cost.
- Backup: an unsupported filesystem or unknown kind fails with *SnapshotError*.
The kind is stated, not probed — an inconclusive probe would fall back to a
live copy and hand out the torn backup the mode prevents. *--shutdown* is
rejected alongside *--snapshot* rather than ignored, since nothing is stopped.
- Refactor: *backup/app.py* splits into *layout.py*, *policy.py* and *dumps.py*
along the lines it already had; 276 lines down to 124.
- Tests: unit coverage for snapshot, layout, policy, volume and CLI validation.
E2E cases drive real btrfs, zfs and ext4 on loop devices, one proving the
loud refusal; another writes a MariaDB across the snapshot and requires the
restored server to recover on its own with every committed row and none of
the later ones. CI installs zfs and sets *E2E_REQUIRE_FILESYSTEMS*, so a
missing kernel module fails the build instead of skipping a filesystem.
## [3.1.4] - 2026-07-31
- Backup: the post-stop volume pass now compares by content (*--checksum*), so
it can no longer skip a file the hot pass had copied from a live source. Each
volume is rsynced twice into the same destination — once with the container
running, once after it is stopped — and the second pass used rsync's quick
check (size plus whole-second mtime). A pre-allocated 16 MiB WAL segment never
changes size, so when its last pre-stop write and postgres' shutdown
checkpoint fell in the same whole second, the cold pass skipped it while still
replacing *global/pg_control*, whose previous write was seconds earlier. The
generation then paired a post-shutdown *pg_control* with a WAL segment still
zeroed at the recorded checkpoint LSN, and restoring it crash-looped postgres
with "invalid record length … expected at least 24, got 0" followed by "PANIC:
could not locate a valid checkpoint record". *backup_volume* takes
*authoritative* as a required keyword rather than an optional flag, so each of
the four call sites states which pass it is; the hot passes keep the quick
check. *--ignore-times* was rejected because it destroys the *--link-dest*
hardlink dedup (measured 20/20 to 0/20, generation size doubled), and
*--modify-window=-1* because it makes correctness depend on the destination
filesystem preserving sub-second mtimes, which degrades silently on NFS or
ext3.
- Cost: the quick check scales with file count, *--checksum* with bytes read on
both sides, and it runs while the container is stopped. The extra stop time
stays under a minute up to roughly 4 GB on spinning disk, 15 GB on a SATA SSD
and 60 GB on NVMe; at 1 TB it is 17 minutes on NVMe and over three hours on
spinning disk. No size threshold is built in, since a guessed one would drop
the guarantee exactly where an unrestorable backup costs most — volumes at
that scale want filesystem snapshots or *pg_basebackup* rather than two rsync
passes over a live tree.
## [3.1.3] - 2026-07-20
- Restore: the postgres dump replay now runs under *--single-transaction*,
so a concurrent writer on a live database can no longer interleave a row
between the replay's table re-create and its *COPY* and trip a "duplicate
key value violates unique constraint" abort under ON_ERROR_STOP. This is
the discourse restore-drill race (*mini_scheduler* upserting
*scheduler_stats(id=1)* mid-restore) that failed the whole restore. The
*--empty* pre-clean stays multi-statement (*\gexec*, one DROP per
statement) because a single DROP transaction exhausts
*max_locks_per_transaction* on large schemas (e.g. gitlab).
- Refactor: the *--empty* pre-clean SQL moves out of the inline Python
string into *src/baudolo/restore/db/empty_preclean.sql* (loaded via
*dirname(__file__)*, declared as package-data so it ships in the wheel).
- Tests: a unit test guards the single-transaction / multi-statement split
(replay carries *--single-transaction*, pre-clean does not); a new e2e
reproduces the live-writer race and asserts the restore survives it.
## [3.1.2] - 2026-07-18
- Restore: the postgres *--empty* pre-clean also drops user-owned text
search configurations and dictionaries (*pg_ts_config*, *pg_ts_dict*),
so a schema shipping a custom dictionary (e.g. taiga's
*english_stem_nostop*) no longer aborts the replay with "duplicate key
value violates unique constraint pg_ts_dict_dictname_index" under
ON_ERROR_STOP.
- Tests: the string-assertion unit test for the pre-clean SQL is replaced
by real scenario data in the e2e: the seeded schema contains an
overloaded *f()/f(int)* pair and the nostop dictionary plus
configuration, and the restored database is queried to prove each
survives the backup, pre-clean and replay cycle exactly once.
## [3.1.1] - 2026-07-17
- Restore: the postgres *--empty* pre-clean drops functions and procedures
by their identity signature (*pg_get_function_identity_arguments*), so a
schema that overloads a function name (e.g. discourse) no longer aborts
the replay with "function name is not unique" under ON_ERROR_STOP.
Identifier quoting moves from the outer DROP format into each object
branch, since the *name(args)* compound must not be quoted as a whole; a
unit test pins the per-branch *%I* quoting so future branches cannot
regress unquoted.
## [3.1.0] - 2026-07-15
- Restore: the postgres *--empty* pre-clean emits one DROP per object and
runs them via *\gexec* instead of a single DO-block, so large schemas
(e.g. gitlab) no longer exhaust *max_locks_per_transaction* in one
transaction. It also drops user-owned non-public schemas, so dumps that
CREATE SCHEMA (e.g. discourse's *discourse_functions*) no longer abort
on the already-existing schema under ON_ERROR_STOP.
- Backup: *--database-containers* and *--images-no-stop-required* are now
optional and default to an empty list, so a pure file backup needs no
dummy arguments; an empty stop whitelist keeps the conservative
stop-all behavior.
- Tests: new e2e test restores *--empty* against a fully populated
database containing a non-public schema and every dropped object class.
*make test* runs the three suites concurrently after a single
clean+build; *E2E_TEST_PATTERN* runs an e2e subset.
## [3.0.0] - 2026-07-12
- Backup: *--images-no-stop-required* and *--images-no-backup-required* now
match a container's exact *.Config.Image* (full *repo:tag*, registry
prefix included) instead of a substring, so a near-miss image name no
longer flips the stop/skip decision. Callers must pass exact image
references. **Breaking.**
- Backup: renamed *--hard-compose-restart* to *--hard-restart-projects*
(its value stays a list of compose project dir names). **Breaking:** the
old flag name is removed.
## [2.0.0] - 2026-07-12
- Backup: renamed *--docker-compose-hard-restart-required* to
*--hard-compose-restart* and changed its default from *["mailu"]* to *[]*
(nargs="*"). The compose down/up is now opt-in: compose hosts pass
*mailu* explicitly, while swarm hosts pass nothing, since there the dir is
a stack whose overlay network collides with *compose up*. **Breaking:** the
old flag name is removed and the implicit mailu default is gone.
- Backup: *--backups-dir* is now required (no */var/lib/backup/* default) so
a run can never silently target the wrong backup root. **Breaking.**
- Restore: volume files are rsynced directly into the target volume's
mountpoint (resolved via *docker volume inspect*), mirroring the backup
path; the *alpine-rsync* helper image and the *--rsync-image* flag are
gone. The caller needs write access to the docker volume root (root on the
host, baudolo's normal privilege). **Breaking:** the restore *files*
subcommand no longer accepts *--rsync-image*.
- Tests: the e2e suite tracks *postgres:alpine* (18+, mounted at
*/var/lib/postgresql*) and *mariadb:latest* from a single source of truth.
## [1.8.1] - 2026-07-12
- Restore: the postgres empty mode also drops user-owned collations in
public; dumps containing CREATE COLLATION (e.g. OpenProject's ICU
collation versions_name) no longer abort the replay with 'collation
already exists'.
- Maintenance: base image bumped from python 3.11-slim to 3.14-slim.
## [1.8.0] - 2026-07-11
Swarm-aware backups and replayable restores.
- Backup: swarm task containers are never stopped or started manually
anymore; they are skipped visibly and backed up hot, while the sql dump
stays the consistent database backup.
- Backup: a container that vanishes between listing and inspect no longer
aborts the run; a failing inspect on a container that still exists keeps
failing loudly.
- Backup: pg_dump runs with the no-owner and no-privileges flags so dumps
are replayable by the owning app user.
- Restore: the mariadb empty mode drops all tables in one client session
with FOREIGN_KEY_CHECKS disabled; FK-linked parent tables no longer abort
the replay with ERROR 1451.
- Restore: the postgres empty mode drops only current-user-owned objects,
and the replay skips superuser-only dump lines without ever touching
COPY data blocks.
- Restore: the replay streams the dump through a temp file instead of
buffering it in memory; multi-GB dumps no longer OOM the restore.
- Tooling: the e2e runner reaches the DinD daemon via docker exec instead
of a host-published unencrypted API port.
- Tooling: new end-to-end test reproducing the swarm stop flake, plus unit
tests for the restore filters and the swarm probes; the suite is 36 unit,
9 integration and 30 e2e tests.
- Tooling: Dependabot with auto-merge for minor and patch updates.
## [1.7.1] - 2026-05-26
* 🔌 MariaDB SQL backups now connect over TCP loopback so the dump always matches the same wildcard-host grant the application uses — no more surprise `ERROR 1045 Access denied` when a localhost-bound auth row preempts.
* 🧪 New regression and bug-repro tests pin the TCP behaviour and prove it under the exact preemption setup that caused the production failure on MariaDB 12.
* 🩺 E2E test infrastructure: DinD bridge and inner daemon now default to MTU 1280 so registry pulls survive host paths with broken PMTUD (override via `E2E_DIND_MTU`).
## [1.7.0] - 2026-02-07
* 🚀 Backup jobs now support all valid Docker Compose file names case-insensitive and hassle-free.
## [1.6.0] - 2026-02-06
* Compose handling is now fully delegated to the Infinito.Nexus compose wrapper or plain docker compose, removing all custom env and file detection to ensure a single, consistent source of truth.
## [1.5.0] - 2026-01-31
* * Make `databases.csv` optional: missing or empty files now emit warnings and no longer break backups
* Fix Docker CLI compatibility by switching to `docker-ce-cli` and required build tools
## [1.4.0] - 2026-01-31
* Baudolo now restarts Docker Compose stacks in a wrapper-aware way (with a `docker compose` fallback), ensuring that all Compose overrides and env files are applied identically to the Infinito.Nexus workflow.
## [1.3.0] - 2026-01-10
* Empty databases.csv no longer causes baudolo-seed to fail
## [1.2.0] - 2025-12-29
* * Introduced **`--dump-only-sql`** mode for reliable, SQL-only database backups (replaces `--dump-only`).
* Database configuration in `databases.csv` is now **strict and explicit** (`*` or concrete database name only).
* **PostgreSQL cluster backups** are supported via `*`.
* SQL dumps are written **atomically** to avoid corrupted or empty files.
* Backups are **smarter and faster**: ignored volumes are skipped early, file backups run only when needed.
* Improved reliability through expanded end-to-end tests and safer defaults.
## [1.1.1] - 2025-12-28
* * **Backup:** In ***--dump-only-sql*** mode, fall back to file backups with a warning when no database dump can be produced (e.g. missing `databases.csv` entry).
## [1.1.0] - 2025-12-28
* * **Backup:** Log a warning and skip database dumps when no databases.csv entry is present instead of raising an exception; introduce module-level logging and apply formatting cleanups across backup/restore code and tests.
* **CLI:** Switch to an FHS-compliant default backup directory (/var/lib/backup) and use a stable default repository name instead of dynamic detection.
* **Maintenance:** Update mirror configuration and ignore generated .egg-info files.
## [1.0.0] - 2025-12-27
* Official Release 🥳

37
Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
FROM python:3.14-slim
WORKDIR /app
# Base deps for build/runtime + docker repo key
RUN apt-get update && apt-get install -y --no-install-recommends \
make \
rsync \
ca-certificates \
bash \
curl \
gnupg \
&& rm -rf /var/lib/apt/lists/*
# Install Docker CLI (docker-ce-cli) from Docker's official apt repo
RUN bash -lc "set -euo pipefail \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg \
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
&& chmod a+r /etc/apt/keyrings/docker.gpg \
&& . /etc/os-release \
&& echo \"deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \${VERSION_CODENAME} stable\" \
> /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli \
&& rm -rf /var/lib/apt/lists/*"
# Fail fast if docker client is missing
RUN docker version || true
RUN command -v docker
COPY . .
RUN make install
ENV PYTHONUNBUFFERED=1
CMD ["baudolo", "--help"]

4
MIRRORS Normal file
View File

@@ -0,0 +1,4 @@
git@github.com:kevinveenbirkenbach/backup-docker-to-local.git
ssh://git@git.veen.world:2201/kevinveenbirkenbach/backup-docker-to-local.git
ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/backup-docker-to-local.git
https://pypi.org/project/backup-docker-to-local/

65
Makefile Normal file
View File

@@ -0,0 +1,65 @@
.PHONY: install build clean \
test test-unit test-integration test-e2e \
test-unit-run test-integration-run test-e2e-run
# Default python if no venv is active
PY_DEFAULT ?= python3
IMAGE_NAME ?= baudolo
IMAGE_TAG ?= local
IMAGE := $(IMAGE_NAME):$(IMAGE_TAG)
install:
@set -eu; \
PY="$(PY_DEFAULT)"; \
if [ -n "$${VIRTUAL_ENV:-}" ] && [ -x "$${VIRTUAL_ENV}/bin/python" ]; then \
PY="$${VIRTUAL_ENV}/bin/python"; \
fi; \
echo ">>> Using python: $$PY"; \
"$$PY" -m pip install --upgrade pip; \
"$$PY" -m pip install -e .; \
command -v baudolo >/dev/null 2>&1 || { \
echo "ERROR: baudolo not found on PATH after install"; \
exit 2; \
}; \
baudolo --help >/dev/null 2>&1 || true
# ------------------------------------------------------------
# Build the baudolo Docker image
# ------------------------------------------------------------
build:
@echo ">> Building Docker image $(IMAGE)"
docker build -t $(IMAGE) .
clean:
git clean -fdX .
# clean + build run once and in order, then the three suites run concurrently
# via -j3; the *-run targets carry no clean/build prereq so the sub-make cannot
# race a second clean against build.
test:
@$(MAKE) clean
@$(MAKE) build
@$(MAKE) -j3 test-unit-run test-integration-run test-e2e-run
test-unit: clean build test-unit-run
test-integration: clean build test-integration-run
test-e2e: clean build test-e2e-run
test-unit-run:
@echo ">> Running unit tests"
@docker run --rm -t $(IMAGE) \
bash -lc 'python -m unittest discover -t . -s tests/unit -p "test_*.py" -v'
test-integration-run:
@echo ">> Running integration tests"
@docker run --rm -t $(IMAGE) \
bash -lc 'python -m unittest discover -t . -s tests/integration -p "test_*.py" -v'
# E2E via isolated Docker-in-Docker (DinD): starts a DinD daemon on a dedicated
# network, loads the freshly built image into it, and runs tests/e2e inside a
# container that talks to DinD via DOCKER_HOST.
test-e2e-run:
@bash scripts/test-e2e.sh

240
README.md
View File

@@ -1,67 +1,217 @@
# Backup Docker Volumes to Local # baudolo Deterministic Backup & Restore for Docker Volumes 📦🔄
[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](./LICENSE.txt) [![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) [![Docker Version](https://img.shields.io/badge/Docker-Yes-blue.svg)](https://www.docker.com) [![Python Version](https://img.shields.io/badge/Python-3.x-blue.svg)](https://www.python.org) [![GitHub stars](https://img.shields.io/github/stars/kevinveenbirkenbach/backup-docker-to-local.svg?style=social)](https://github.com/kevinveenbirkenbach/backup-docker-to-local/stargazers)
## goal
This script backups all docker-volumes with the help of rsync.
## scheme `baudolo` is a backup and restore system for Docker volumes with
It is part of the following scheme: **mandatory file backups** and **explicit, deterministic database dumps**.
![backup scheme](https://www.veen.world/wp-content/uploads/2020/12/server-backup-768x567.jpg) It is designed for environments with many Docker services where:
Further information you will find [in this blog post](https://www.veen.world/2020/12/26/how-i-backup-dedicated-root-servers/). - file-level backups must always exist
- database dumps must be intentional, predictable, and auditable
## Backup all volumes ## ✨ Key Features
Execute:
```bash - 📦 Incremental Docker volume backups using `rsync --link-dest`
./backup-docker-to-local.sh - 🗄 Optional SQL dumps for:
- PostgreSQL
- MariaDB / MySQL
- 🌱 Explicit database definition for SQL backups (no auto-discovery)
- 🧾 Backup integrity stamping via `dirval` (Python API)
- ⏸ Automatic container stop/start when required for consistency
- 🚫 Whitelisting of containers that do not require stopping
- ♻️ Modular, maintainable Python architecture
## 🧠 Core Concept (Important!)
`baudolo` **separates file backups from database dumps**.
- **Docker volumes are always backed up at file level**
- **SQL dumps are created only for explicitly defined databases**
This results in the following behavior:
| Database defined | File backup | SQL dump |
|------------------|-------------|----------|
| No | ✔ yes | ✘ no |
| Yes | ✔ yes | ✔ yes |
## 📁 Backup Layout
Backups are stored in a deterministic, fully nested structure:
```text
<backups-dir>/
└── <machine-hash>/
└── <repo-name>/
└── <timestamp>/
└── <volume-name>/
├── files/
└── sql/
└── <database>.backup.sql
``` ```
## Recover ### Meaning of each level
### database * `<machine-hash>`
```bash SHA256 hash of `/etc/machine-id` (host separation)
docker exec -i mysql_container mysql -uroot -psecret database < db.sql
```
### volume * `<repo-name>`
Execute: Logical backup namespace (project / stack)
* `<timestamp>`
Backup generation (`YYYYMMDDHHMMSS`)
* `<volume-name>`
Docker volume name
* `files/`
Incremental file backup (rsync)
* `sql/`
Optional SQL dumps (only for defined databases)
## 🚀 Installation
### Local (editable install)
```bash ```bash
python3 -m venv .venv
bash ./recover-docker-from-local.sh "{{volume_name}}" "$(sha256sum /etc/machine-id | head -c 64)" "{{version_to_recover}}" source .venv/bin/activate
pip install -e .
``` ```
### Database ## 🌱 Database Definition (SQL Backup Scope)
## Debug ### How SQL backups are defined
To checkout what's going on in the mount container type in the following command:
`baudolo` creates SQL dumps **only** for databases that are **explicitly defined**
via configuration (e.g. a databases definition file or seeding step).
If a database is **not defined**:
* its Docker volume is still backed up (files)
* **no SQL dump is created**
> No database definition → file backup only
> Database definition present → file backup + SQL dump
### Why explicit definition?
`baudolo` does **not** inspect running containers to guess databases.
Databases must be explicitly defined to guarantee:
* deterministic backups
* predictable restore behavior
* reproducible environments
* zero accidental production data exposure
### Required database metadata
Each database definition provides:
* database instance (container or logical instance)
* database name
* database user
* database password
This information is used by `baudolo` to execute
`pg_dump`, `pg_dumpall`, or `mariadb-dump`.
## 💾 Running a Backup
```bash ```bash
docker run -it --entrypoint /bin/sh --rm --volumes-from {{container_name}} -v /Backups/:/Backups/ kevinveenbirkenbach/alpine-rsync baudolo \
--compose-dir /srv/docker \
--databases-csv /etc/baudolo/databases.csv \
--database-containers central-postgres central-mariadb \
--images-no-stop-required alpine postgres mariadb mysql \
--images-no-backup-required redis busybox
``` ```
## Setup ### Common Backup Flags
Install pandas
## Author | Flag | Description |
| --------------- | ------------------------------------------- |
| `--everything` | Always stop containers and re-run rsync |
| `--dump-only-sql`| Skip file backups only for DB volumes when dumps succeed; non-DB volumes are still backed up; fallback to files if no dump. |
| `--shutdown` | Do not restart containers after backup |
| `--backups-dir` | Backup root directory (default: `/Backups`) |
| `--repo-name` | Backup namespace under machine hash |
Kevin Veen-Birkenbach ## ♻️ Restore Operations
- 📧 Email: [kevin@veen.world](mailto:kevin@veen.world)
- 🌍 Website: [https://www.veen.world/](https://www.veen.world/)
## License ### Restore Volume Files
This project is licensed under the GNU Affero General Public License v3.0. The full license text is available in the `LICENSE` file of this repository. ```bash
baudolo-restore files \
my-volume \
<machine-hash> \
<version> \
--backups-dir /Backups \
--repo-name my-repo
```
## More information Restore into a **different target volume**:
- https://docs.docker.com/storage/volumes/
- https://blog.ssdnodes.com/blog/docker-backup-volumes/ ```bash
- https://www.baculasystems.com/blog/docker-backup-containers/ baudolo-restore files \
- https://gist.github.com/spalladino/6d981f7b33f6e0afe6bb target-volume \
- https://stackoverflow.com/questions/26331651/how-can-i-backup-a-docker-container-with-its-data-volumes <machine-hash> \
- https://netfuture.ch/2013/08/simple-versioned-timemachine-like-backup-using-rsync/ <version> \
- https://zwischenzugs.com/2016/08/29/bash-to-python-converter/ --source-volume source-volume
- https://en.wikipedia.org/wiki/Incremental_backup#Incremental ```
- https://unix.stackexchange.com/questions/567837/linux-backup-utility-for-incremental-backups
- https://chat.openai.com/share/6d10f143-3f7c-4feb-8ae9-5644c3433a65 ### Restore PostgreSQL
```bash
baudolo-restore postgres \
my-volume \
<machine-hash> \
<version> \
--container postgres \
--db-name appdb \
--db-password secret \
--empty
```
### Restore MariaDB / MySQL
```bash
baudolo-restore mariadb \
my-volume \
<machine-hash> \
<version> \
--container mariadb \
--db-name shopdb \
--db-password secret \
--empty
```
> `baudolo` automatically detects whether `mariadb` or `mysql`
> is available inside the container
## 🔍 Backup Scheme
The backup mechanism uses incremental backups with rsync and stamps directories with a unique hash. For more details on the backup scheme, check out [this blog post](https://blog.veen.world/blog/2020/12/26/how-i-backup-dedicated-root-servers/).
![Backup Scheme](https://blog.veen.world/wp-content/uploads/2020/12/server-backup-1024x755.jpg)
## 👨‍💻 Author
**Kevin Veen-Birkenbach**
- 📧 [kevin@veen.world](mailto:kevin@veen.world)
- 🌐 [https://www.veen.world/](https://www.veen.world/)
## 📜 License
This project is licensed under the **GNU Affero General Public License v3.0**. See the [LICENSE](./LICENSE) file for details.
## 🔗 More Information
- [Docker Volumes Documentation](https://docs.docker.com/storage/volumes/)
- [Docker Backup Volumes Blog](https://blog.ssdnodes.com/blog/docker-backup-volumes/)
- [Backup Strategies](https://en.wikipedia.org/wiki/Incremental_backup#Incremental)
---
Happy Backing Up! 🚀🔐

View File

@@ -1,290 +0,0 @@
#!/bin/python
# Backups volumes of running containers
import subprocess
import os
import re
import pathlib
import pandas
from datetime import datetime
import argparse
class BackupException(Exception):
"""Generic exception for backup errors."""
pass
def execute_shell_command(command):
"""Execute a shell command and return its output."""
print(command)
process = subprocess.Popen([command], stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
out, err = process.communicate()
if process.returncode != 0:
raise BackupException(f"Error in command: {command}\nOutput: {out}\nError: {err}\nExit code: {process.returncode}")
return [line.decode("utf-8") for line in out.splitlines()]
def create_version_directory():
"""Create necessary directories for backup."""
version_dir = os.path.join(VERSIONS_DIR, BACKUP_TIME)
pathlib.Path(version_dir).mkdir(parents=True, exist_ok=True)
return version_dir
def get_machine_id():
"""Get the machine identifier."""
return execute_shell_command("sha256sum /etc/machine-id")[0][0:64]
### GLOBAL CONFIGURATION ###
IMAGES_NO_STOP_REQUIRED = [
'akaunting',
'baserow',
'discourse',
'element',
'gitea',
'listmonk',
'mastodon',
'matomo',
'nextcloud',
'openproject',
'peertube',
'pixelfed',
'wordpress'
]
IMAGES_NO_BACKUP_REQUIRED = [
'redis',
'memcached'
]
DIRNAME = os.path.dirname(__file__)
DATABASES = pandas.read_csv(os.path.join(DIRNAME, "databases.csv"), sep=";")
REPOSITORY_NAME = os.path.basename(DIRNAME)
MACHINE_ID = get_machine_id()
BACKUPS_DIR = '/Backups/'
VERSIONS_DIR = os.path.join(BACKUPS_DIR, MACHINE_ID, REPOSITORY_NAME)
BACKUP_TIME = datetime.now().strftime("%Y%m%d%H%M%S")
VERSION_DIR = create_version_directory()
def get_instance(container):
# The function is defined to take one parameter, 'container',
# which is expected to be a string.
# This line uses regular expressions to split the 'container' string.
# 're.split' is a method that divides a string into a list, based on the occurrences of a pattern.
instance_name = re.split("(_|-)(database|db|postgres)", container)[0]
# The pattern "(_|-)(database|db|postgres)" is explained as follows:
# - "(_|-)": Matches an underscore '_' or a hyphen '-'.
# - "(database|db|postgres)": Matches one of the strings "database", "db", or "postgres".
# So, this pattern will match segments like "_database", "-db", "_postgres", etc.
# For example, in "central-db", it matches "-db".
# After splitting, [0] is used to select the first element of the list resulting from the split.
# This element is the string portion before the matched pattern.
# For "central-db", the split results in ["central", "db"], and [0] selects "central".
print(f"Extracted instance name: {instance_name}")
return instance_name
def backup_database(container, volume_dir, db_type):
"""Backup database (MariaDB or PostgreSQL) if applicable."""
print(f"Starting database backup for {container} using {db_type}...")
instance_name = get_instance(container)
# Filter the DataFrame for the given instance_name
database_entries = DATABASES.loc[DATABASES['instance'] == instance_name]
# Check if there are more than one entries
if len(database_entries) > 1:
raise BackupException(f"More than one entry found for instance '{instance_name}'")
# Check if there is no entry
if database_entries.empty:
raise BackupException(f"No entry found for instance '{instance_name}'")
# Get the first (and only) entry
for database_entry in database_entries.iloc:
database_name = database_entry['database']
database_username = database_entry['username']
database_password = database_entry['password']
backup_destination_dir = os.path.join(volume_dir, "sql")
pathlib.Path(backup_destination_dir).mkdir(parents=True, exist_ok=True)
backup_destination_file = os.path.join(backup_destination_dir, f"{database_name}.backup.sql")
if db_type == 'mariadb':
backup_command = f"docker exec {container} /usr/bin/mariadb-dump -u {database_username} -p{database_password} {database_name} > {backup_destination_file}"
elif db_type == 'postgres':
if database_password:
# Include PGPASSWORD in the command when a password is provided
backup_command = (
f"PGPASSWORD={database_password} docker exec -i {container} "
f"pg_dump -U {database_username} -d {database_name} "
f"-h localhost > {backup_destination_file}"
)
else:
# Exclude PGPASSWORD and use --no-password when the password is empty
backup_command = (
f"docker exec -i {container} pg_dump -U {database_username} "
f"-d {database_name} -h localhost --no-password "
f"> {backup_destination_file}"
)
execute_shell_command(backup_command)
print(f"Database backup for database {container} completed.")
def get_last_backup_dir(volume_name, current_backup_dir):
"""Get the most recent backup directory for the specified volume."""
versions = sorted(os.listdir(VERSIONS_DIR), reverse=True)
for version in versions:
backup_dir = os.path.join(VERSIONS_DIR, version, volume_name, "files")
# Ignore current backup dir
if backup_dir != current_backup_dir:
if os.path.isdir(backup_dir):
return backup_dir
print(f"No previous backups available for volume: {volume_name}")
return None
def getStoragePath(volume_name):
return execute_shell_command(f"docker volume inspect --format '{{{{ .Mountpoint }}}}' {volume_name}")
def backup_volume(volume_name, volume_dir):
"""Backup files of a volume with incremental backups."""
print(f"Starting backup routine for volume: {volume_name}")
files_rsync_destination_path = os.path.join(volume_dir, "files")
pathlib.Path(files_rsync_destination_path).mkdir(parents=True, exist_ok=True)
last_backup_dir = get_last_backup_dir(volume_name, files_rsync_destination_path)
link_dest_option = f"--link-dest='{last_backup_dir}'" if last_backup_dir else ""
source_dir = getStoragePath(volume_name)
rsync_command = f"rsync -abP --delete --delete-excluded {link_dest_option} {source_dir} {files_rsync_destination_path}"
execute_shell_command(rsync_command)
print(f"Backup routine for volume: {volume_name} completed.")
def get_image_info(container):
return execute_shell_command(f"docker inspect --format '{{{{.Config.Image}}}}' {container}")
def has_image(container,image):
"""Check if the container is using the image"""
image_info = get_image_info(container)
return image in image_info[0]
def stop_containers(containers):
"""Stop a list of containers."""
for container in containers:
print(f"Stopping container {container}...")
execute_shell_command(f"docker stop {container}")
def start_containers(containers):
"""Start a list of stopped containers."""
for container in containers:
print(f"Starting container {container}...")
execute_shell_command(f"docker start {container}")
def get_container_with_image(containers,image):
for container in containers:
if has_image(container,image):
return container
return False
def is_image_whitelisted(container, images):
"""Check if the container's image is one of the whitelisted images."""
image_info = get_image_info(container)
container_image = image_info[0]
for image in images:
if image in container_image:
return True
return False
def is_container_stop_required(containers):
"""Check if any of the containers are using images that are not whitelisted."""
return any(not is_image_whitelisted(container, IMAGES_NO_STOP_REQUIRED) for container in containers)
def create_volume_directory(volume_name):
"""Create necessary directories for backup."""
volume_dir = os.path.join(VERSION_DIR, volume_name)
pathlib.Path(volume_dir).mkdir(parents=True, exist_ok=True)
return volume_dir
def is_image_ignored(container):
"""Check if the container's image is one of the ignored images."""
for image in IMAGES_NO_BACKUP_REQUIRED:
if has_image(container, image):
return True
return False
def backup_with_containers_paused(volume_name, volume_dir, containers, shutdown):
stop_containers(containers)
backup_volume(volume_name, volume_dir)
# Just restart containers if shutdown is false
if not shutdown:
start_containers(containers)
def backup_mariadb_or_postgres(container, volume_dir):
'''Performs database image specific backup procedures'''
for image in ['mariadb','postgres']:
if has_image(container, image):
backup_database(container, volume_dir, image)
return True
return False
def default_backup_routine_for_volume(volume_name, containers, shutdown):
"""Perform backup routine for a given volume."""
volume_dir=""
for container in containers:
# Skip ignored images
if is_image_ignored(container):
print(f"Ignoring volume '{volume_name}' linked to container '{container}' with ignored image.")
continue
# Directory which contains files and sqls
volume_dir = create_volume_directory(volume_name)
# Execute Database backup and exit if successfull
if backup_mariadb_or_postgres(container, volume_dir):
return
# Execute backup if image is not ignored
if volume_dir:
backup_volume(volume_name, volume_dir)
if is_container_stop_required(containers):
backup_with_containers_paused(volume_name, volume_dir, containers, shutdown)
def backup_everything(volume_name, containers, shutdown):
"""Perform file backup routine for a given volume."""
volume_dir=create_volume_directory(volume_name)
# Execute sql dumps
for container in containers:
backup_mariadb_or_postgres(container, volume_dir)
# Execute file backups
backup_volume(volume_name, volume_dir)
backup_with_containers_paused(volume_name, volume_dir, containers, shutdown)
def main():
parser = argparse.ArgumentParser(description='Backup Docker volumes.')
parser.add_argument('--everything', action='store_true',
help='Force file backup for all volumes and additional execute database dumps')
parser.add_argument('--shutdown', action='store_true',
help='Doesn\'t restart containers after backup')
args = parser.parse_args()
print('Start volume backups...')
volume_names = execute_shell_command("docker volume ls --format '{{.Name}}'")
for volume_name in volume_names:
print(f'Start backup routine for volume: {volume_name}')
containers = execute_shell_command(f"docker ps --filter volume=\"{volume_name}\" --format '{{{{.Names}}}}'")
if not containers:
print('Skipped due to no running containers using this volume.')
continue
if args.everything:
backup_everything(volume_name, containers, args.shutdown)
else:
default_backup_routine_for_volume(volume_name, containers, args.shutdown)
print('Finished volume backups.')
if __name__ == "__main__":
main()

View File

@@ -1,44 +0,0 @@
import pandas as pd
import argparse
import os
def check_and_add_entry(file_path, instance, database, username, password):
# Check if the file exists and is not empty
if os.path.exists(file_path) and os.path.getsize(file_path) > 0:
# Read the existing CSV file with header
df = pd.read_csv(file_path, sep=';')
else:
# Create a new DataFrame with columns if file does not exist
df = pd.DataFrame(columns=['instance', 'database', 'username', 'password'])
# Check if the entry exists and remove it
mask = (df['instance'] == instance) & (df['database'] == database) & (df['username'] == username)
if not df[mask].empty:
print("Replacing existing entry.")
df = df[~mask]
else:
print("Adding new entry.")
# Create a new DataFrame for the new entry
new_entry = pd.DataFrame([{'instance': instance, 'database': database, 'username': username, 'password': password}])
# Add (or replace) the entry using concat
df = pd.concat([df, new_entry], ignore_index=True)
# Save the updated CSV file
df.to_csv(file_path, sep=';', index=False)
def main():
parser = argparse.ArgumentParser(description="Check and replace (or add) a database entry in a CSV file.")
parser.add_argument("file_path", help="Path to the CSV file")
parser.add_argument("instance", help="Database instance")
parser.add_argument("database", help="Database name")
parser.add_argument("username", help="Username")
parser.add_argument("password", nargs='?', default="", help="Password (optional)")
args = parser.parse_args()
check_and_add_entry(args.file_path, args.instance, args.database, args.username, args.password)
if __name__ == "__main__":
main()

View File

@@ -1 +0,0 @@
database;username;password;container

32
pyproject.toml Normal file
View File

@@ -0,0 +1,32 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "backup-docker-to-local"
version = "3.2.2"
description = "Backup Docker volumes to local with rsync and optional DB dumps."
readme = "README.md"
requires-python = ">=3.9"
license = { text = "AGPL-3.0-or-later" }
authors = [{ name = "Kevin Veen-Birkenbach" }]
dependencies = [
"pandas",
"dirval",
]
[project.scripts]
baudolo = "baudolo.backup.__main__:main"
baudolo-restore = "baudolo.restore.__main__:main"
baudolo-seed = "baudolo.seed.__main__:main"
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
exclude = ["tests*"]
[tool.setuptools.package-data]
"baudolo.restore.db" = ["*.sql"]

View File

@@ -1,61 +0,0 @@
#!/bin/bash
# Check minimum number of arguments
if [ $# -lt 3 ]; then
echo "ERROR: Not enough arguments. Please provide at least a volume name, backup hash, and version."
exit 1
fi
volume_name="$1" # Volume-Name
backup_hash="$2" # Hashed Machine ID
version="$3" # version to backup
container="${4:-}" # optional
mysql_root_password="${5:-}" # optional
database="${6:-}" # optional
backup_folder="Backups/$backup_hash/backup-docker-to-local/$version/$volume_name"
backup_files="/$backup_folder/files"
backup_sql="/$backup_folder/sql/backup.sql"
echo "Inspect volume $volume_name"
docker volume inspect "$volume_name"
exit_status_volume_inspect=$?
if [ $exit_status_volume_inspect -eq 0 ]; then
echo "Volume $volume_name already exists"
else
echo "Create volume $volume_name"
docker volume create "$volume_name"
if [ $? -ne 0 ]; then
echo "ERROR: Failed to create volume $volume_name"
exit 1
fi
fi
if [ -f "$backup_sql" ]; then
if [ -n "$container" ] && [ -n "$mysql_root_password" ] && [ -n "$database" ]; then
echo "recover mysql dump"
cat "$backup_sql" | docker exec -i "$container" mariadb -u root --password="$mysql_root_password" "$database"
if [ $? -ne 0 ]; then
echo "ERROR: Failed to recover mysql dump"
exit 1
fi
exit 0
fi
echo "A database backup exists, but a parameter is missing. Files will be recovered instead."
fi
if [ -d "$backup_files" ]; then
echo "recover files"
docker run --rm -v "$volume_name:/recover/" -v "$backup_files:/backup/" "kevinveenbirkenbach/alpine-rsync" sh -c "rsync -avv --delete /backup/ /recover/"
if [ $? -ne 0 ]; then
echo "ERROR: Failed to recover files"
exit 1
fi
exit 0
else
echo "ERROR: $backup_files doesn't exist"
exit 1
fi
echo "ERROR: Unhandled case"
exit 1

230
scripts/test-e2e.sh Executable file
View File

@@ -0,0 +1,230 @@
#!/usr/bin/env bash
set -euo pipefail
# -----------------------------------------------------------------------------
# E2E runner using Docker-in-Docker (DinD) with debug-on-failure
#
# Debug toggles:
# E2E_KEEP_ON_FAIL=1 -> keep DinD + volumes + network if tests fail
# E2E_KEEP_VOLUMES=1 -> keep volumes even on success/cleanup
# E2E_DEBUG_SHELL=1 -> open an interactive shell in the test container instead of running tests
# E2E_ARTIFACTS_DIR=./artifacts
# -----------------------------------------------------------------------------
NET="${E2E_NET:-baudolo-e2e-net}"
DIND="${E2E_DIND_NAME:-baudolo-e2e-dind}"
DIND_VOL="${E2E_DIND_VOL:-baudolo-e2e-dind-data}"
E2E_TMP_VOL="${E2E_TMP_VOL:-baudolo-e2e-tmp}"
# Host-side access to the DinD daemon goes through `docker exec` (dind()
# below) instead of a host-published port: port publishing is not reachable
# from every environment (sandboxed runners, hosts with broken loopback
# publishing), while exec only needs the outer docker socket. The TCP
# listener stays for the test container inside the dedicated network.
DIND_HOST_IN_NET="${E2E_DIND_HOST_IN_NET:-tcp://${DIND}:2375}"
dind() { docker exec "${DIND}" docker "$@"; }
dind_stdin() { docker exec -i "${DIND}" docker "$@"; }
IMG="${E2E_IMAGE:-baudolo:local}"
READY_TIMEOUT_SECONDS="${E2E_READY_TIMEOUT_SECONDS:-120}"
ARTIFACTS_DIR="${E2E_ARTIFACTS_DIR:-./artifacts}"
DIND_MTU="${E2E_DIND_MTU:-1280}"
KEEP_ON_FAIL="${E2E_KEEP_ON_FAIL:-0}"
KEEP_VOLUMES="${E2E_KEEP_VOLUMES:-0}"
DEBUG_SHELL="${E2E_DEBUG_SHELL:-0}"
# Override to run a subset, e.g. E2E_TEST_PATTERN=test_e2e_postgres_empty_drop_hard.py
TEST_PATTERN="${E2E_TEST_PATTERN:-test_*.py}"
FAILED=0
TS="$(date +%Y%m%d%H%M%S)"
mkdir -p "${ARTIFACTS_DIR}"
log() { echo ">> $*"; }
dump_debug() {
log "DEBUG: collecting diagnostics into ${ARTIFACTS_DIR}"
{
echo "=== Host docker version ==="
docker version || true
echo
echo "=== Host docker info ==="
docker info || true
echo
echo "=== DinD reachable? (docker exec ${DIND} docker version) ==="
dind version || true
echo
} > "${ARTIFACTS_DIR}/debug-host-${TS}.txt" 2>&1 || true
# DinD logs
docker logs --tail=5000 "${DIND}" > "${ARTIFACTS_DIR}/dind-logs-${TS}.txt" 2>&1 || true
# DinD state
{
echo "=== dind ps -a ==="
dind ps -a || true
echo
echo "=== dind images ==="
dind images || true
echo
echo "=== dind network ls ==="
dind network ls || true
echo
echo "=== dind volume ls ==="
dind volume ls || true
echo
echo "=== dind system df ==="
dind system df || true
} > "${ARTIFACTS_DIR}/debug-dind-${TS}.txt" 2>&1 || true
# Try to capture recent events (best effort; might be noisy)
dind events --since 10m --until 0s \
> "${ARTIFACTS_DIR}/dind-events-${TS}.txt" 2>&1 || true
# The shared tmp volume is mounted at /tmp inside the DinD container
# itself, so tar it there and copy it out with the outer daemon.
log "DEBUG: archiving shared /tmp (volume ${E2E_TMP_VOL})"
docker exec "${DIND}" tar -czf "/tmpdump-${TS}.tar.gz" -C /tmp . >/dev/null 2>&1 || true
docker cp "${DIND}:/tmpdump-${TS}.tar.gz" "${ARTIFACTS_DIR}/e2e-tmp-${TS}.tar.gz" >/dev/null 2>&1 || true
log "DEBUG: artifacts written:"
find "${ARTIFACTS_DIR}" -maxdepth 1 -mindepth 1 -print | sed 's/^/ /' || true
}
cleanup() {
if [ "${FAILED}" -eq 1 ] && [ "${KEEP_ON_FAIL}" = "1" ]; then
log "KEEP_ON_FAIL=1 and failure detected -> skipping cleanup."
log "Next steps:"
echo " - Inspect DinD logs: docker logs ${DIND} | less"
echo " - Use DinD daemon: docker exec ${DIND} docker ps -a"
echo " - Shared tmp vol: docker exec ${DIND} ls -la /tmp"
echo " - DinD docker root: docker exec ${DIND} ls -la /var/lib/docker/volumes"
return 0
fi
log "Cleanup: stopping ${DIND} and removing network ${NET}"
docker rm -f "${DIND}" >/dev/null 2>&1 || true
docker network rm "${NET}" >/dev/null 2>&1 || true
if [ "${KEEP_VOLUMES}" != "1" ]; then
docker volume rm -f "${DIND_VOL}" >/dev/null 2>&1 || true
docker volume rm -f "${E2E_TMP_VOL}" >/dev/null 2>&1 || true
else
log "Keeping volumes (E2E_KEEP_VOLUMES=1): ${DIND_VOL}, ${E2E_TMP_VOL}"
fi
}
trap cleanup EXIT INT TERM
log "(Re)creating network ${NET} with MTU ${DIND_MTU}"
docker network rm "${NET}" >/dev/null 2>&1 || true
docker network create \
--opt com.docker.network.driver.mtu="${DIND_MTU}" \
"${NET}" >/dev/null
log "Removing old ${DIND} (if any)"
docker rm -f "${DIND}" >/dev/null 2>&1 || true
log "(Re)creating DinD data volume ${DIND_VOL}"
docker volume rm -f "${DIND_VOL}" >/dev/null 2>&1 || true
docker volume create "${DIND_VOL}" >/dev/null
log "(Re)creating shared /tmp volume ${E2E_TMP_VOL}"
docker volume rm -f "${E2E_TMP_VOL}" >/dev/null 2>&1 || true
docker volume create "${E2E_TMP_VOL}" >/dev/null
log "Starting Docker-in-Docker daemon ${DIND}"
docker run -d --privileged \
--name "${DIND}" \
--network "${NET}" \
-e DOCKER_TLS_CERTDIR="" \
-v "${DIND_VOL}:/var/lib/docker" \
-v "${E2E_TMP_VOL}:/tmp" \
docker:dind \
--host=tcp://0.0.0.0:2375 \
--tls=false \
--mtu="${DIND_MTU}" >/dev/null
log "Waiting for DinD to be ready..."
for i in $(seq 1 "${READY_TIMEOUT_SECONDS}"); do
if dind version >/dev/null 2>&1; then
log "DinD is ready."
break
fi
sleep 1
if [ "${i}" -eq "${READY_TIMEOUT_SECONDS}" ]; then
echo "ERROR: DinD did not become ready in time"
docker logs --tail=200 "${DIND}" || true
FAILED=1
dump_debug || true
exit 1
fi
done
log "Ensuring alpine exists in DinD (for debug helpers)"
dind pull alpine:3.20 >/dev/null
log "Loading ${IMG} image into DinD..."
docker save "${IMG}" | dind_stdin load >/dev/null
log "Running E2E tests inside DinD"
set +e
if [ "${DEBUG_SHELL}" = "1" ]; then
log "E2E_DEBUG_SHELL=1 -> opening shell in test container"
docker run --rm -it \
--network "${NET}" \
-e DOCKER_HOST="${DIND_HOST_IN_NET}" \
-v "${DIND_VOL}:/var/lib/docker" \
-v "${E2E_TMP_VOL}:/tmp" \
"${IMG}" \
bash -lc '
set -e
if [ ! -f /etc/machine-id ]; then
mkdir -p /etc
cat /proc/sys/kernel/random/uuid > /etc/machine-id
fi
echo ">> DOCKER_HOST=${DOCKER_HOST}"
docker ps -a || true
exec bash
'
rc=$?
else
docker run --rm \
--network "${NET}" \
-e DOCKER_HOST="${DIND_HOST_IN_NET}" \
-e E2E_TEST_PATTERN="${TEST_PATTERN}" \
-e E2E_REQUIRE_FILESYSTEMS="${E2E_REQUIRE_FILESYSTEMS:-}" \
-v "${DIND_VOL}:/var/lib/docker" \
-v "${E2E_TMP_VOL}:/tmp" \
"${IMG}" \
bash -lc '
set -euo pipefail
set -x
export PYTHONUNBUFFERED=1
export TMPDIR=/tmp TMP=/tmp TEMP=/tmp
if [ ! -f /etc/machine-id ]; then
mkdir -p /etc
cat /proc/sys/kernel/random/uuid > /etc/machine-id
fi
python -m unittest discover -t . -s tests/e2e -p "${E2E_TEST_PATTERN}" -v -f
'
rc=$?
fi
set -e
if [ "${rc}" -ne 0 ]; then
FAILED=1
echo "ERROR: E2E tests failed (exit code: ${rc})"
dump_debug || true
exit "${rc}"
fi
log "E2E tests passed."

0
src/baudolo/__init__.py Normal file
View File

View File

@@ -0,0 +1 @@
"""Baudolo backup package."""

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env python3
from __future__ import annotations
from .app import main
if __name__ == "__main__":
raise SystemExit(main())

133
src/baudolo/backup/app.py Normal file
View File

@@ -0,0 +1,133 @@
"""Back up every Docker volume of a host into a timestamped generation."""
from __future__ import annotations
import os
from contextlib import ExitStack
from datetime import datetime
from .cli import parse_args
from .compose import handle_docker_compose_services
from .docker import (
change_containers_status,
containers_using_volume,
docker_volume_names,
filter_stoppable,
)
from .dumps import backup_dumps_for_volume, load_databases_df
from .layout import (
create_version_directory,
create_volume_directory,
get_machine_id,
stamp_directory,
)
from .policy import requires_stop, volume_is_fully_ignored
from .snapshot import volume_snapshot
from .volume import backup_volume, get_storage_path
def main() -> int:
args = parse_args()
machine_id = get_machine_id()
backup_time = datetime.now().strftime("%Y%m%d%H%M%S")
versions_dir = os.path.join(args.backups_dir, machine_id, args.repo_name)
version_dir = create_version_directory(versions_dir, backup_time)
# IMPORTANT:
# - keep_default_na=False prevents empty fields from turning into NaN
# - dtype=str keeps all columns stable for comparisons/validation
#
# Robust behavior:
# - if the file is missing or empty, we continue without DB dumps.
databases_df = load_databases_df(args.databases_csv)
print("💾 Start volume backups...", flush=True)
with ExitStack() as stack:
resolve_source = None
if args.snapshot:
resolve_source = stack.enter_context(
volume_snapshot(args.snapshot, args.snapshot_subject, backup_time)
)
for volume_name in docker_volume_names():
print(f"Start backup routine for volume: {volume_name}", flush=True)
containers = containers_using_volume(volume_name)
if volume_is_fully_ignored(containers, args.images_no_backup_required):
print(
f"Skipping volume '{volume_name}' entirely (all linked containers are ignored).",
flush=True,
)
continue
vol_dir = create_volume_directory(version_dir, volume_name)
found_db, dumped_any = backup_dumps_for_volume(
containers=containers,
vol_dir=vol_dir,
databases_df=databases_df,
database_containers=args.database_containers,
)
if args.dump_only_sql:
if found_db:
if not dumped_any:
print(
f"WARNING: dump-only-sql requested but no DB dump was produced for DB volume '{volume_name}'. "
"Falling back to file backup.",
flush=True,
)
else:
continue
live_source = get_storage_path(volume_name)
def copy(*, authoritative: bool, source: str = live_source) -> None:
backup_volume(
versions_dir,
volume_name,
vol_dir,
authoritative=authoritative,
source=source,
)
if resolve_source is not None:
snapshot_source = resolve_source(live_source)
if os.path.isdir(snapshot_source):
copy(authoritative=True, source=snapshot_source)
else:
print(
f"WARNING: volume '{volume_name}' is not in the snapshot "
"(created after it was taken); copying it live instead.",
flush=True,
)
copy(authoritative=False)
continue
if args.everything:
stoppable = filter_stoppable(containers)
copy(authoritative=False)
change_containers_status(stoppable, "stop")
copy(authoritative=True)
if not args.shutdown:
change_containers_status(stoppable, "start")
continue
copy(authoritative=False)
if requires_stop(containers, args.images_no_stop_required):
stoppable = filter_stoppable(containers)
change_containers_status(stoppable, "stop")
copy(authoritative=True)
if not args.shutdown:
change_containers_status(stoppable, "start")
stamp_directory(version_dir)
print("Finished volume backups.", flush=True)
print("Handling Docker Compose services...", flush=True)
handle_docker_compose_services(args.compose_dir, args.hard_restart_projects)
return 0

102
src/baudolo/backup/cli.py Normal file
View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import argparse
import os
def parse_args() -> argparse.Namespace:
dirname = os.path.dirname(__file__)
default_databases_csv = os.path.join(dirname, "databases.csv")
p = argparse.ArgumentParser(description="Backup Docker volumes.")
p.add_argument(
"--compose-dir",
type=str,
required=True,
help="Path to the parent directory containing docker-compose setups",
)
p.add_argument(
"--hard-restart-projects",
nargs="*",
default=[],
help="Compose dir names that require 'docker-compose down && up -d' (default: none; pass e.g. 'mailu' under compose where the DB cannot be backed up hot)",
)
p.add_argument(
"--repo-name",
default="backup-docker-to-local",
help="Backup repo folder name under <backups-dir>/<machine-id>/ (default: git repo folder name)",
)
p.add_argument(
"--databases-csv",
default=default_databases_csv,
help=f"Path to databases.csv (default: {default_databases_csv})",
)
p.add_argument(
"--backups-dir",
required=True,
help="Backup root directory (e.g. /var/lib/backup/)",
)
p.add_argument(
"--snapshot",
choices=["btrfs", "zfs"],
help="Capture every volume from one atomic filesystem snapshot instead of copying the live tree. Containers are not stopped, and the copy is a single pass. Requires --snapshot-subject. Omit to keep the live two-pass copy.",
)
p.add_argument(
"--snapshot-subject",
help="Btrfs subvolume or zfs dataset mountpoint holding the docker volumes, e.g. /var/lib/docker. Required with --snapshot.",
)
p.add_argument(
"--database-containers",
nargs="+",
default=[],
help="Container names treated as special instances for database backups",
)
p.add_argument(
"--images-no-stop-required",
nargs="+",
default=[],
help="Exact image references (repo:tag, incl. any registry prefix) whose containers must not be stopped during file backup",
)
p.add_argument(
"--images-no-backup-required",
nargs="+",
default=[],
help="Exact image references (repo:tag, incl. any registry prefix) for which no backup should be performed",
)
p.add_argument(
"--everything",
action="store_true",
help="Force file backup for all volumes and also execute database dumps (like old script)",
)
p.add_argument(
"--shutdown",
action="store_true",
help="Do not restart containers after backup",
)
p.add_argument(
"--dump-only-sql",
action="store_true",
help=(
"Create database dumps only for DB volumes. "
"File backups are skipped for DB volumes if a dump succeeds, "
"but non-DB volumes are still backed up. "
"If a DB dump cannot be produced, baudolo falls back to a file backup."
),
)
args = p.parse_args()
if bool(args.snapshot) != bool(args.snapshot_subject):
p.error("--snapshot and --snapshot-subject must be given together")
if args.snapshot and args.shutdown:
p.error("--shutdown is meaningless with --snapshot: containers are never stopped")
if args.snapshot and args.hard_restart_projects:
p.error(
"--hard-restart-projects is meaningless with --snapshot: the flag exists "
"for stacks whose database cannot be backed up hot, which a snapshot solves"
)
return args

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
from typing import List, Optional
def _build_compose_cmd(project_dir: str, passthrough: List[str]) -> List[str]:
"""
Build the compose command for this project directory.
Policy:
- If `compose` wrapper exists (Infinito.Nexus): use it and delegate ALL logic to it.
- Else: use plain `docker compose` with --chdir.
- NO custom compose file/env detection in this project.
"""
pdir = Path(project_dir).resolve()
wrapper = shutil.which("compose")
if wrapper:
# "--" ensures wrapper stops parsing its own args.
return [wrapper, "--chdir", str(pdir), "--", *passthrough]
docker = shutil.which("docker")
if docker:
return [docker, "compose", "--chdir", str(pdir), *passthrough]
raise RuntimeError("Neither 'compose' nor 'docker' found in PATH")
def _find_compose_file(project_dir: str) -> Optional[Path]:
"""
Detect a compose file in `project_dir` (case-insensitive).
Supported names:
- compose.yml / compose.yaml
- docker-compose.yml / docker-compose.yaml
"""
pdir = Path(project_dir)
if not pdir.is_dir():
return None
# Map lowercase filename -> actual Path (preserves original casing)
by_lower = {p.name.lower(): p for p in pdir.iterdir() if p.is_file()}
# Preferred order (policy decision)
candidates = [
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
]
for name in candidates:
found = by_lower.get(name)
if found is not None:
return found
return None
def hard_restart_docker_services(dir_path: str) -> None:
print(f"Hard restart compose services in: {dir_path}", flush=True)
down_cmd = _build_compose_cmd(dir_path, ["down"])
up_cmd = _build_compose_cmd(dir_path, ["up", "-d"])
print(">>> " + " ".join(down_cmd), flush=True)
subprocess.run(down_cmd, check=True)
print(">>> " + " ".join(up_cmd), flush=True)
subprocess.run(up_cmd, check=True)
def handle_docker_compose_services(
parent_directory: str,
hard_restart_required: list[str],
) -> None:
for entry in os.scandir(parent_directory):
if not entry.is_dir():
continue
dir_path = entry.path
name = os.path.basename(dir_path)
print(f"Checking directory: {dir_path}", flush=True)
compose_file = _find_compose_file(dir_path)
if compose_file is None:
print("No supported compose file found. Skipping.", flush=True)
continue
if name in hard_restart_required:
print(f"{name}: hard restart required.", flush=True)
hard_restart_docker_services(dir_path)
else:
print(f"{name}: no restart required.", flush=True)

147
src/baudolo/backup/db.py Normal file
View File

@@ -0,0 +1,147 @@
from __future__ import annotations
import os
import pathlib
import re
import logging
from typing import Optional
import pandas
from .shell import BackupException, execute_shell_command
log = logging.getLogger(__name__)
def get_instance(container: str, database_containers: list[str]) -> str:
"""
Derive a stable instance name from the container name.
"""
if container in database_containers:
return container
return re.split(r"(_|-)(database|db|postgres)", container)[0]
def _validate_database_value(value: Optional[str], *, instance: str) -> str:
"""
Enforce explicit database semantics:
- "*" => dump ALL databases (cluster dump for Postgres)
- "<name>" => dump exactly this database
- "" => invalid configuration (would previously result in NaN / nan.backup.sql)
"""
v = (value or "").strip()
if v == "":
raise ValueError(
f"Invalid databases.csv entry for instance '{instance}': "
"column 'database' must be '*' or a concrete database name (not empty)."
)
return v
def _atomic_write_cmd(cmd: str, out_file: str) -> None:
"""
Write dump output atomically:
- write to <file>.tmp
- rename to <file> only on success
This prevents empty or partial dump files from being treated as valid backups.
"""
tmp = f"{out_file}.tmp"
execute_shell_command(f"{cmd} > {tmp}")
execute_shell_command(f"mv {tmp} {out_file}")
def fallback_pg_dumpall(
container: str, username: str, password: str, out_file: str
) -> None:
"""
Perform a full Postgres cluster dump using pg_dumpall.
"""
cmd = (
f"PGPASSWORD={password} docker exec -i {container} "
f"pg_dumpall -U {username} -h localhost"
)
_atomic_write_cmd(cmd, out_file)
def backup_database(
*,
container: str,
volume_dir: str,
db_type: str,
databases_df: "pandas.DataFrame",
database_containers: list[str],
) -> bool:
"""
Backup databases for a given DB container.
Returns True if at least one dump was produced.
"""
instance_name = get_instance(container, database_containers)
entries = databases_df[databases_df["instance"] == instance_name]
if entries.empty:
log.debug("No database entries for instance '%s'", instance_name)
return False
out_dir = os.path.join(volume_dir, "sql")
pathlib.Path(out_dir).mkdir(parents=True, exist_ok=True)
produced = False
for row in entries.itertuples(index=False):
raw_db = getattr(row, "database", "")
user = (getattr(row, "username", "") or "").strip()
password = (getattr(row, "password", "") or "").strip()
db_value = _validate_database_value(raw_db, instance=instance_name)
# Explicit: dump ALL databases
if db_value == "*":
if db_type != "postgres":
raise ValueError(
f"databases.csv entry for instance '{instance_name}': "
"'*' is currently only supported for Postgres."
)
cluster_file = os.path.join(out_dir, f"{instance_name}.cluster.backup.sql")
fallback_pg_dumpall(container, user, password, cluster_file)
produced = True
continue
# Concrete database dump
db_name = db_value
dump_file = os.path.join(out_dir, f"{db_name}.backup.sql")
if db_type == "mariadb":
# Force TCP so auth matches '<user>'@'%' instead of socket -> 'localhost'.
cmd = (
f"docker exec {container} /usr/bin/mariadb-dump "
f"-h 127.0.0.1 --protocol=tcp "
f"-u {user} -p{password} {db_name}"
)
_atomic_write_cmd(cmd, dump_file)
produced = True
continue
if db_type == "postgres":
try:
cmd = (
f"PGPASSWORD={password} docker exec -i {container} "
f"pg_dump -U {user} -d {db_name} -h localhost "
f"--no-owner --no-privileges"
)
_atomic_write_cmd(cmd, dump_file)
produced = True
except BackupException as e:
# Explicit DB dump failed -> hard error
raise BackupException(
f"Postgres dump failed for instance '{instance_name}', "
f"database '{db_name}'. This database was explicitly configured "
"and therefore must succeed.\n"
f"{e}"
)
continue
return produced

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from .shell import BackupException, execute_shell_command
def get_image_info(container: str) -> str:
return execute_shell_command(
f"docker inspect --format '{{{{.Config.Image}}}}' {container}"
)[0]
def has_image(container: str, pattern: str) -> bool:
"""Return True if container's image contains the pattern."""
return pattern in get_image_info(container)
def docker_volume_names() -> list[str]:
return execute_shell_command("docker volume ls --format '{{.Name}}'")
def containers_using_volume(volume_name: str) -> list[str]:
return execute_shell_command(
f"docker ps --filter volume=\"{volume_name}\" --format '{{{{.Names}}}}'"
)
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. A container that
vanished between listing and inspect (--rm one-shots, task-history GC)
counts as not stoppable instead of aborting the whole backup run; if the
container still exists the inspect failure re-raises, so a broken daemon
keeps failing the run loudly instead of silently skipping the stop."""
try:
out = execute_shell_command(
"docker inspect --format "
f"'{{{{index .Config.Labels \"com.docker.swarm.task.id\"}}}}' {container}"
)
except BackupException:
still_listed = execute_shell_command(
f"docker ps -a --filter name=^{container}$ --format '{{{{.Names}}}}'"
)
if still_listed and still_listed[0].strip():
raise
return True
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:
print(f"No containers to {status}.", flush=True)
return
names = " ".join(containers)
print(f"{status.capitalize()} containers: {names}...", flush=True)
execute_shell_command(f"docker {status} {names}")
def docker_volume_exists(volume: str) -> bool:
# Avoid throwing exceptions for exists checks.
try:
execute_shell_command(
f"docker volume inspect {volume} >/dev/null 2>&1 && echo OK"
)
return True
except Exception:
return False

View File

@@ -0,0 +1,98 @@
"""Database dumps taken before a volume's files are copied."""
from __future__ import annotations
import sys
import pandas
from pandas.errors import EmptyDataError
from .db import backup_database
from .docker import has_image
def backup_mariadb_or_postgres(
*,
container: str,
volume_dir: str,
databases_df: "pandas.DataFrame",
database_containers: list[str],
) -> tuple[bool, bool]:
"""
Returns (is_db_container, dumped_any)
"""
for img in ["mariadb", "postgres"]:
if has_image(container, img):
dumped = backup_database(
container=container,
volume_dir=volume_dir,
db_type=img,
databases_df=databases_df,
database_containers=database_containers,
)
return True, dumped
return False, False
def _empty_databases_df() -> "pandas.DataFrame":
"""
Create an empty DataFrame with the expected schema for databases.csv.
This allows the backup to continue without DB dumps when the CSV is missing
or empty (pandas EmptyDataError).
"""
return pandas.DataFrame(columns=["instance", "database", "username", "password"])
def load_databases_df(csv_path: str) -> "pandas.DataFrame":
"""
Load databases.csv robustly.
- Missing file -> warn, continue with empty df
- Empty file -> warn, continue with empty df
- Valid CSV -> return dataframe
"""
try:
return pandas.read_csv(csv_path, sep=";", keep_default_na=False, dtype=str)
except FileNotFoundError:
print(
f"WARNING: databases.csv not found: {csv_path}. Continuing without database dumps.",
file=sys.stderr,
flush=True,
)
return _empty_databases_df()
except EmptyDataError:
print(
f"WARNING: databases.csv exists but is empty: {csv_path}. Continuing without database dumps.",
file=sys.stderr,
flush=True,
)
return _empty_databases_df()
def backup_dumps_for_volume(
*,
containers: list[str],
vol_dir: str,
databases_df: "pandas.DataFrame",
database_containers: list[str],
) -> tuple[bool, bool]:
"""
Returns (found_db_container, dumped_any)
"""
found_db = False
dumped_any = False
for c in containers:
is_db, dumped = backup_mariadb_or_postgres(
container=c,
volume_dir=vol_dir,
databases_df=databases_df,
database_containers=database_containers,
)
if is_db:
found_db = True
if dumped:
dumped_any = True
return found_db, dumped_any

View File

@@ -0,0 +1,33 @@
"""Where a backup run puts its files, and how a finished run is stamped."""
from __future__ import annotations
import os
import pathlib
from dirval import create_stamp_file
from .shell import execute_shell_command
def get_machine_id() -> str:
return execute_shell_command("sha256sum /etc/machine-id")[0][0:64]
def stamp_directory(version_dir: str) -> None:
"""
Use dirval as a Python library to stamp the directory (no CLI dependency).
"""
create_stamp_file(version_dir)
def create_version_directory(versions_dir: str, backup_time: str) -> str:
version_dir = os.path.join(versions_dir, backup_time)
pathlib.Path(version_dir).mkdir(parents=True, exist_ok=True)
return version_dir
def create_volume_directory(version_dir: str, volume_name: str) -> str:
path = os.path.join(version_dir, volume_name)
pathlib.Path(path).mkdir(parents=True, exist_ok=True)
return path

View File

@@ -0,0 +1,38 @@
"""Which volumes are backed up, and which containers must stop for it."""
from __future__ import annotations
from .docker import get_image_info, is_swarm_task
def is_image_ignored(container: str, images_no_backup_required: list[str]) -> bool:
if not images_no_backup_required:
return False
img = get_image_info(container)
return img in images_no_backup_required
def volume_is_fully_ignored(
containers: list[str], images_no_backup_required: list[str]
) -> bool:
"""
Skip file backup only if all containers linked to the volume are ignored.
"""
if not containers:
return False
return all(is_image_ignored(c, images_no_backup_required) for c in containers)
def requires_stop(containers: list[str], images_no_stop_required: list[str]) -> bool:
"""
Stop is required if ANY stoppable container image is NOT in the exact
image whitelist. 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 img not in images_no_stop_required:
return True
return False

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
import subprocess
class BackupException(Exception):
"""Generic exception for backup errors."""
def execute_shell_command(command: str) -> list[str]:
"""Execute a shell command and return its output lines."""
print(command, flush=True)
process = subprocess.Popen(
[command],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
shell=True,
)
out, err = process.communicate()
if process.returncode != 0:
raise BackupException(
f"Error in command: {command}\n"
f"Output: {out}\nError: {err}\n"
f"Exit code: {process.returncode}"
)
return [line.decode("utf-8") for line in out.splitlines()]

View File

@@ -0,0 +1,98 @@
"""Capture every volume from one atomic filesystem snapshot.
Copying a live tree file by file cannot produce a point in time: a database can
write between two files and leave a control file and its write-ahead log
disagreeing, which no recovery can repair. A snapshot freezes the whole subject
at once, so a database reads it as a crash and replays its log - a case it is
built for. That also removes the reason to stop containers at all.
The snapshot kind is stated by the caller rather than probed, because falling
back to a live copy when a probe is inconclusive would hand out backups that
look consistent and are not.
"""
from __future__ import annotations
import os
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from .shell import BackupException, execute_shell_command
KINDS = ("btrfs", "zfs")
class SnapshotError(RuntimeError):
"""A snapshot could not be created, resolved or removed."""
def _resolver(subject: str, root: str) -> Callable[[str], str]:
def resolve(path: str) -> str:
relative = os.path.relpath(os.path.abspath(path), os.path.abspath(subject))
if relative.startswith(".."):
raise SnapshotError(f"{path} lies outside the snapshot subject {subject}")
resolved = root if relative == "." else os.path.join(root, relative)
# abspath drops a trailing separator, and rsync reads "dir/" as its
# contents where "dir" means the directory itself.
return resolved + os.sep if path.endswith(os.sep) else resolved
return resolve
def _btrfs(subject: str, name: str, run: Callable[[str], list[str]]) -> tuple[str, str]:
# The snapshot goes inside the subject, never beside it: the kernel rejects
# a snapshot whose destination is on another filesystem, which is exactly
# what the parent directory is when the subject is a mountpoint of its own.
target = os.path.join(os.path.abspath(subject), f".{name}")
run(f"btrfs subvolume snapshot -r {subject} {target}")
return target, f"btrfs subvolume delete {target}"
def _zfs(subject: str, name: str, run: Callable[[str], list[str]]) -> tuple[str, str]:
output = run(f"zfs list -H -o name {subject}")
dataset = (output[0] if output else "").strip()
if not dataset:
raise SnapshotError(f"no zfs dataset is mounted at {subject}")
run(f"zfs snapshot {dataset}@{name}")
root = os.path.join(subject, ".zfs", "snapshot", name)
return root, f"zfs destroy {dataset}@{name}"
_CREATE = {"btrfs": _btrfs, "zfs": _zfs}
@contextmanager
def volume_snapshot(
kind: str,
subject: str,
tag: str,
run: Callable[[str], list[str]] = execute_shell_command,
) -> Iterator[Callable[[str], str]]:
"""Yield a resolver mapping a path under ``subject`` into a snapshot of it.
Args:
kind: ``btrfs`` or ``zfs``; the caller states it, nothing is probed.
subject: the btrfs subvolume or zfs dataset mountpoint holding the
volumes, e.g. ``/var/lib/docker``.
tag: unique suffix for the snapshot name, e.g. the backup timestamp.
run: shell runner, injected so the mechanics are testable.
Raises:
SnapshotError: the kind is unknown, or the snapshot cannot be created.
Removal failure is reported, not raised: a leftover snapshot is a
cleanup problem and must not discard a generation that is complete.
"""
create = _CREATE.get(kind)
if create is None:
raise SnapshotError(f"unknown snapshot kind {kind!r}; expected one of {KINDS}")
root, remove = create(subject, f"baudolo-{tag}", run)
try:
yield _resolver(subject, root)
finally:
try:
run(remove)
except BackupException as error:
# Raising here would also mask whatever the body raised.
print(f"WARNING: {root} could not be removed: {error}", flush=True)

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
import os
import pathlib
from .shell import BackupException, execute_shell_command
def get_storage_path(volume_name: str) -> str:
path = execute_shell_command(
f"docker volume inspect --format '{{{{ .Mountpoint }}}}' {volume_name}"
)[0]
return f"{path}/"
def get_last_backup_dir(
versions_dir: str, volume_name: str, current_backup_dir: str
) -> str | None:
versions = sorted(os.listdir(versions_dir), reverse=True)
for version in versions:
candidate = os.path.join(versions_dir, version, volume_name, "files", "")
if candidate != current_backup_dir and os.path.isdir(candidate):
return candidate
return None
def backup_volume(
versions_dir: str,
volume_name: str,
volume_dir: str,
*,
authoritative: bool,
source: str,
) -> None:
"""Perform incremental file backup of a Docker volume.
Args:
authoritative: compare source and destination by content instead of by
size and whole-second mtime. Required on a pass whose destination was
already written from a live source, where a file can differ while
both attributes still agree.
source: directory to read from - the volume's mountpoint, or its path
inside a snapshot.
"""
dest = os.path.join(volume_dir, "files") + "/"
pathlib.Path(dest).mkdir(parents=True, exist_ok=True)
last = get_last_backup_dir(versions_dir, volume_name, dest)
link_dest = f"--link-dest='{last}'" if last else ""
verify = "--checksum " if authoritative else ""
cmd = f"rsync -abP --delete --delete-excluded {verify}{link_dest} {source} {dest}"
try:
execute_shell_command(cmd)
except BackupException as e:
if "file has vanished" in str(e):
print(
"Warning: Some files vanished before transfer. Continuing.", flush=True
)
else:
raise

View File

@@ -0,0 +1 @@
__all__ = ["main"]

View File

@@ -0,0 +1,141 @@
from __future__ import annotations
import argparse
import sys
from .paths import BackupPaths
from .files import restore_volume_files
from .db.postgres import restore_postgres_sql
from .db.mariadb import restore_mariadb_sql
def _add_common_backup_args(p: argparse.ArgumentParser) -> None:
p.add_argument("volume_name", help="Docker volume name (target volume)")
p.add_argument("backup_hash", help="Hashed machine id")
p.add_argument("version", help="Backup version directory name")
p.add_argument(
"--backups-dir",
default="/Backups",
help="Backup root directory (default: /Backups)",
)
p.add_argument(
"--repo-name",
default="backup-docker-to-local",
help="Backup repo folder name under <backups-dir>/<hash>/ (default: backup-docker-to-local)",
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="baudolo-restore",
description="Restore docker volume files and DB dumps.",
)
sub = parser.add_subparsers(dest="cmd", required=True)
# ------------------------------------------------------------------
# files
# ------------------------------------------------------------------
p_files = sub.add_parser("files", help="Restore files into a docker volume")
_add_common_backup_args(p_files)
p_files.add_argument(
"--source-volume",
default=None,
help=(
"Volume name used as backup source path key. "
"Defaults to <volume_name> (target volume). "
"Use this when restoring from one volume backup into a different target volume."
),
)
# ------------------------------------------------------------------
# postgres
# ------------------------------------------------------------------
p_pg = sub.add_parser("postgres", help="Restore a single PostgreSQL database dump")
_add_common_backup_args(p_pg)
p_pg.add_argument("--container", required=True)
p_pg.add_argument("--db-name", required=True)
p_pg.add_argument("--db-user", default=None, help="Defaults to db-name if omitted")
p_pg.add_argument("--db-password", required=True)
p_pg.add_argument("--empty", action="store_true")
# ------------------------------------------------------------------
# mariadb
# ------------------------------------------------------------------
p_mdb = sub.add_parser(
"mariadb", help="Restore a single MariaDB/MySQL-compatible dump"
)
_add_common_backup_args(p_mdb)
p_mdb.add_argument("--container", required=True)
p_mdb.add_argument("--db-name", required=True)
p_mdb.add_argument("--db-user", default=None, help="Defaults to db-name if omitted")
p_mdb.add_argument("--db-password", required=True)
p_mdb.add_argument("--empty", action="store_true")
args = parser.parse_args(argv)
try:
if args.cmd == "files":
# target volume = args.volume_name
# source volume (backup key) defaults to target volume
source_volume = args.source_volume or args.volume_name
bp_files = BackupPaths(
source_volume,
args.backup_hash,
args.version,
repo_name=args.repo_name,
backups_dir=args.backups_dir,
)
return restore_volume_files(
args.volume_name,
bp_files.files_dir(),
)
if args.cmd == "postgres":
user = args.db_user or args.db_name
restore_postgres_sql(
container=args.container,
db_name=args.db_name,
user=user,
password=args.db_password,
sql_path=BackupPaths(
args.volume_name,
args.backup_hash,
args.version,
repo_name=args.repo_name,
backups_dir=args.backups_dir,
).sql_file(args.db_name),
empty=args.empty,
)
return 0
if args.cmd == "mariadb":
user = args.db_user or args.db_name
restore_mariadb_sql(
container=args.container,
db_name=args.db_name,
user=user,
password=args.db_password,
sql_path=BackupPaths(
args.volume_name,
args.backup_hash,
args.version,
repo_name=args.repo_name,
backups_dir=args.backups_dir,
).sql_file(args.db_name),
empty=args.empty,
)
return 0
parser.error("Unhandled command")
return 2
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1 @@
"""Database restore handlers (Postgres, MariaDB/MySQL)."""

View File

@@ -0,0 +1,64 @@
-- Owner-filtered pre-clean for `restore --empty`. Emitted as one DROP per row and
-- run via \gexec so each executes as its own top-level statement: a single DO-block
-- would run every DROP in one transaction and exhaust max_locks_per_transaction on
-- large schemas (e.g. gitlab). Also drops user-owned non-public schemas so a dump
-- that CREATE SCHEMAs (e.g. discourse's discourse_functions) does not fail on an
-- already-existing schema. Extension members (pg_trgm's set_limit) are
-- superuser-owned; IF EXISTS absorbs the CASCADE fallout.
SELECT format('DROP %s IF EXISTS public.%s CASCADE', obj.type, obj.name)
FROM (
SELECT format('%I', 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
UNION ALL
-- Overloaded functions share a proname; DROP needs the identity
-- signature or psql aborts with "function name is not unique".
SELECT format('%I(%s)', p.proname, pg_get_function_identity_arguments(p.oid)) 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
UNION ALL
SELECT format('%I', 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 format('%I', 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')))
UNION ALL
SELECT format('%I', col.collname) AS name, 'COLLATION' AS type
FROM pg_collation col JOIN pg_namespace n ON n.oid = col.collnamespace
WHERE n.nspname = 'public'
AND pg_get_userbyid(col.collowner) = current_user
UNION ALL
SELECT format('%I', ts.cfgname) AS name, 'TEXT SEARCH CONFIGURATION' AS type
FROM pg_ts_config ts JOIN pg_namespace n ON n.oid = ts.cfgnamespace
WHERE n.nspname = 'public'
AND pg_get_userbyid(ts.cfgowner) = current_user
UNION ALL
SELECT format('%I', d.dictname) AS name, 'TEXT SEARCH DICTIONARY' AS type
FROM pg_ts_dict d JOIN pg_namespace n ON n.oid = d.dictnamespace
WHERE n.nspname = 'public'
AND pg_get_userbyid(d.dictowner) = current_user
) obj
UNION ALL
SELECT format('DROP SCHEMA IF EXISTS %I CASCADE', n.nspname)
FROM pg_namespace n
WHERE NOT starts_with(n.nspname, 'pg_')
AND n.nspname NOT IN ('public', 'information_schema')
AND pg_get_userbyid(n.nspowner) = current_user
\gexec

View File

@@ -0,0 +1,92 @@
from __future__ import annotations
import os
import sys
from ..run import docker_exec, docker_exec_sh
def _pick_client(container: str) -> str:
"""
Prefer 'mariadb', fallback to 'mysql'.
Some MariaDB images no longer ship a 'mysql' binary, so we must not assume it exists.
"""
script = r"""
set -eu
if command -v mariadb >/dev/null 2>&1; then echo mariadb; exit 0; fi
if command -v mysql >/dev/null 2>&1; then echo mysql; exit 0; fi
exit 42
"""
try:
out = docker_exec_sh(container, script, capture=True).stdout.decode().strip()
if not out:
raise RuntimeError("empty client detection output")
return out
except Exception as e:
print(
"ERROR: neither 'mariadb' nor 'mysql' found in container.", file=sys.stderr
)
raise e
def restore_mariadb_sql(
*,
container: str,
db_name: str,
user: str,
password: str,
sql_path: str,
empty: bool,
) -> None:
client = _pick_client(container)
if not os.path.isfile(sql_path):
raise FileNotFoundError(sql_path)
if empty:
# IMPORTANT:
# Do NOT hardcode 'mysql' here. Use the detected client.
# MariaDB 11 images may not contain the mysql binary at all.
result = docker_exec(
container,
[
client,
"-u",
user,
f"--password={password}",
"-N",
"-e",
f"SELECT table_name FROM information_schema.tables WHERE table_schema = '{db_name}';",
],
capture=True,
)
tables = result.stdout.decode().split()
if tables:
# SET FOREIGN_KEY_CHECKS is session-scoped, so it must share one
# client session with the DROPs or FK constraints still fire.
drop_sql = (
"SET FOREIGN_KEY_CHECKS=0; "
+ " ".join(
f"DROP TABLE IF EXISTS `{db_name}`.`{tbl}`;" for tbl in tables
)
+ " SET FOREIGN_KEY_CHECKS=1;"
)
docker_exec(
container,
[
client,
"-u",
user,
f"--password={password}",
"-e",
drop_sql,
],
)
with open(sql_path, "rb") as f:
docker_exec(
container, [client, "-u", user, f"--password={password}", db_name], stdin=f
)
print(f"MariaDB/MySQL restore complete for db '{db_name}'.")

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
import os
import tempfile
from collections.abc import Iterable, Iterator
from ..run import docker_exec
_SUPERUSER_ONLY_PREFIXES = (b"COMMENT ON EXTENSION", b"ALTER DEFAULT PRIVILEGES")
_EMPTY_PRECLEAN_SQL = os.path.join(os.path.dirname(__file__), "empty_preclean.sql")
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
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:
with open(_EMPTY_PRECLEAN_SQL, encoding="utf-8") as preclean:
drop_sql = preclean.read()
docker_exec(
container,
["psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", db_name],
stdin=drop_sql.encode(),
docker_env=docker_env,
)
# 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",
"--single-transaction",
"-v",
"ON_ERROR_STOP=1",
"-U",
user,
"-d",
db_name,
],
stdin=filtered,
docker_env=docker_env,
)
print(f"PostgreSQL restore complete for db '{db_name}'.")

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import os
import sys
from .run import docker_volume_exists, run
def restore_volume_files(volume_name: str, backup_files_dir: str) -> int:
if not os.path.isdir(backup_files_dir):
print(f"ERROR: backup files dir not found: {backup_files_dir}", file=sys.stderr)
return 2
if not docker_volume_exists(volume_name):
print(f"Volume {volume_name} does not exist. Creating...")
run(["docker", "volume", "create", volume_name])
else:
print(f"Volume {volume_name} already exists.")
cp = run(
["docker", "volume", "inspect", "--format", "{{ .Mountpoint }}", volume_name],
capture=True,
)
raw = cp.stdout or b""
mountpoint = (raw.decode() if isinstance(raw, bytes) else raw).strip()
if not mountpoint:
print(
f"ERROR: could not resolve mountpoint for volume {volume_name}",
file=sys.stderr,
)
return 2
src = os.path.join(backup_files_dir, "")
dest = os.path.join(mountpoint, "")
run(["rsync", "-avv", "--delete", src, dest])
print("File restore complete.")
return 0

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class BackupPaths:
volume_name: str
backup_hash: str
version: str
repo_name: str
backups_dir: str = "/Backups"
def root(self) -> str:
# Always build an absolute path under backups_dir
return os.path.join(
self.backups_dir,
self.backup_hash,
self.repo_name,
self.version,
self.volume_name,
)
def files_dir(self) -> str:
return os.path.join(self.root(), "files")
def sql_file(self, db_name: str) -> str:
return os.path.join(self.root(), "sql", f"{db_name}.backup.sql")

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
import subprocess
import sys
from typing import Optional
def run(
cmd: list[str],
*,
stdin=None,
capture: bool = False,
env: Optional[dict] = None,
) -> subprocess.CompletedProcess:
try:
kwargs: dict = {
"check": True,
"capture_output": capture,
"env": env,
}
# If stdin is raw data (bytes/str), pass it via input=.
# IMPORTANT: when using input=..., do NOT pass stdin=... as well.
if isinstance(stdin, (bytes, str)):
kwargs["input"] = stdin
else:
kwargs["stdin"] = stdin
return subprocess.run(cmd, **kwargs)
except subprocess.CalledProcessError as e:
msg = f"ERROR: command failed ({e.returncode}): {' '.join(cmd)}"
print(msg, file=sys.stderr)
if e.stdout:
try:
print(e.stdout.decode(), file=sys.stderr)
except Exception:
print(e.stdout, file=sys.stderr)
if e.stderr:
try:
print(e.stderr.decode(), file=sys.stderr)
except Exception:
print(e.stderr, file=sys.stderr)
raise
def docker_exec(
container: str,
argv: list[str],
*,
stdin=None,
capture: bool = False,
env: Optional[dict] = None,
docker_env: Optional[dict[str, str]] = None,
) -> subprocess.CompletedProcess:
cmd: list[str] = ["docker", "exec", "-i"]
if docker_env:
for k, v in docker_env.items():
cmd.extend(["-e", f"{k}={v}"])
cmd.extend([container, *argv])
return run(cmd, stdin=stdin, capture=capture, env=env)
def docker_exec_sh(
container: str,
script: str,
*,
stdin=None,
capture: bool = False,
env: Optional[dict] = None,
docker_env: Optional[dict[str, str]] = None,
) -> subprocess.CompletedProcess:
return docker_exec(
container,
["sh", "-lc", script],
stdin=stdin,
capture=capture,
env=env,
docker_env=docker_env,
)
def docker_volume_exists(volume: str) -> bool:
p = subprocess.run(
["docker", "volume", "inspect", volume],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return p.returncode == 0

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import re
import sys
import pandas as pd
from typing import Optional
from pandas.errors import EmptyDataError
DB_NAME_RE = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_-]*$")
def _validate_database_value(value: Optional[str], *, instance: str) -> str:
v = (value or "").strip()
if v == "":
raise ValueError(
f"Invalid databases.csv entry for instance '{instance}': "
"column 'database' must be '*' or a concrete database name (not empty)."
)
if v == "*":
return "*"
if v.lower() == "nan":
raise ValueError(
f"Invalid databases.csv entry for instance '{instance}': database must not be 'nan'."
)
if not DB_NAME_RE.match(v):
raise ValueError(
f"Invalid databases.csv entry for instance '{instance}': "
f"invalid database name '{v}'. Allowed: letters, numbers, '_' and '-'."
)
return v
def _empty_df() -> pd.DataFrame:
return pd.DataFrame(columns=["instance", "database", "username", "password"])
def check_and_add_entry(
file_path: str,
instance: str,
database: Optional[str],
username: str,
password: str,
) -> None:
"""
Add or update an entry in databases.csv.
The function enforces strict validation:
- database MUST be set
- database MUST be '*' or a valid database name
"""
database = _validate_database_value(database, instance=instance)
if os.path.exists(file_path):
try:
df = pd.read_csv(
file_path,
sep=";",
dtype=str,
keep_default_na=False,
)
except EmptyDataError:
print(
f"WARNING: databases.csv exists but is empty: {file_path}. Creating header columns.",
file=sys.stderr,
)
df = _empty_df()
else:
df = _empty_df()
mask = (df["instance"] == instance) & (df["database"] == database)
if mask.any():
print("Updating existing entry.")
df.loc[mask, ["username", "password"]] = [username, password]
else:
print("Adding new entry.")
new_entry = pd.DataFrame(
[[instance, database, username, password]],
columns=["instance", "database", "username", "password"],
)
df = pd.concat([df, new_entry], ignore_index=True)
df.to_csv(file_path, sep=";", index=False)
def main() -> None:
parser = argparse.ArgumentParser(
description="Seed or update databases.csv for backup configuration."
)
parser.add_argument("file", help="Path to databases.csv")
parser.add_argument("instance", help="Instance name (e.g. bigbluebutton)")
parser.add_argument(
"database",
help="Database name or '*' to dump all databases",
)
parser.add_argument("username", help="Database username")
parser.add_argument("password", help="Database password")
args = parser.parse_args()
try:
check_and_add_entry(
file_path=args.file,
instance=args.instance,
database=args.database,
username=args.username,
password=args.password,
)
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

0
tests/__init__.py Normal file
View File

0
tests/e2e/__init__.py Normal file
View File

View File

@@ -0,0 +1,4 @@
"""Shared e2e helpers, re-exported so tests import one name."""
from .fixtures import * # noqa: F401,F403
from .process import * # noqa: F401,F403

View File

@@ -0,0 +1,114 @@
"""Fixtures and paths the e2e suite builds its scenarios from."""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
from .process import machine_hash, run
# postgres 18+ mounts at /var/lib/postgresql, not /var/lib/postgresql/data.
POSTGRES_IMAGE = "postgres:alpine"
POSTGRES_DATA_DIR = "/var/lib/postgresql"
MARIADB_IMAGE = "mariadb:latest"
MARIADB_DATA_DIR = "/var/lib/mysql"
def backup_run(
*,
backups_dir: str,
repo_name: str,
compose_dir: str,
databases_csv: str,
database_containers: list[str],
images_no_stop_required: list[str],
images_no_backup_required: list[str] | None = None,
dump_only_sql: bool = False,
) -> None:
cmd = [
"baudolo",
"--compose-dir",
compose_dir,
"--hard-restart-projects",
"mailu",
"--repo-name",
repo_name,
"--databases-csv",
databases_csv,
"--backups-dir",
backups_dir,
"--database-containers",
*database_containers,
"--images-no-stop-required",
*images_no_stop_required,
]
if images_no_backup_required:
cmd += ["--images-no-backup-required", *images_no_backup_required]
if dump_only_sql:
cmd += ["--dump-only-sql"]
try:
run(cmd, capture=True, check=True)
except subprocess.CalledProcessError as e:
print(">>> baudolo failed (exit code:", e.returncode, ")")
if e.stdout:
print(">>> baudolo STDOUT:\n" + e.stdout)
if e.stderr:
print(">>> baudolo STDERR:\n" + e.stderr)
raise
def latest_version_dir(backups_dir: str, repo_name: str) -> tuple[str, str]:
"""
Returns (hash, version) for the latest backup.
"""
h = machine_hash()
root = Path(backups_dir) / h / repo_name
if not root.is_dir():
raise FileNotFoundError(str(root))
versions = sorted([p.name for p in root.iterdir() if p.is_dir()])
if not versions:
raise RuntimeError(f"No versions found under {root}")
return h, versions[-1]
def backup_path(backups_dir: str, repo_name: str, version: str, volume: str) -> Path:
h = machine_hash()
return Path(backups_dir) / h / repo_name / version / volume
def create_minimal_compose_dir(base: str) -> str:
"""
baudolo requires --compose-dir. Create an empty dir with one non-compose subdir.
"""
p = Path(base) / "compose-root"
p.mkdir(parents=True, exist_ok=True)
(p / "noop").mkdir(parents=True, exist_ok=True)
return str(p)
def write_databases_csv(path: str, rows: list[tuple[str, str, str, str]]) -> None:
"""
rows: (instance, database, username, password)
database may be '' (empty) to trigger pg_dumpall behavior if you want, but here we use db name.
"""
Path(path).parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write("instance;database;username;password\n")
for inst, db, user, pw in rows:
f.write(f"{inst};{db};{user};{pw}\n")
def cleanup_docker(*, containers: list[str], volumes: list[str]) -> None:
for c in containers:
run(["docker", "rm", "-f", c], capture=True, check=False)
for v in volumes:
run(["docker", "volume", "rm", "-f", v], capture=True, check=False)
def ensure_empty_dir(path: str) -> None:
p = Path(path)
if p.exists():
shutil.rmtree(p)
p.mkdir(parents=True, exist_ok=True)

View File

@@ -0,0 +1,155 @@
"""Process, docker and readiness helpers for the e2e suite."""
from __future__ import annotations
import subprocess
import time
import uuid
def run(
cmd: list[str],
*,
capture: bool = True,
check: bool = True,
cwd: str | None = None,
) -> subprocess.CompletedProcess:
try:
return subprocess.run(
cmd,
check=check,
cwd=cwd,
text=True,
capture_output=capture,
)
except subprocess.CalledProcessError as e:
# Print captured output so failing E2E tests are "live" / debuggable in CI logs
print(">>> command failed:", " ".join(cmd))
print(">>> exit code:", e.returncode)
if e.stdout:
print(">>> STDOUT:\n" + e.stdout)
if e.stderr:
print(">>> STDERR:\n" + e.stderr)
raise
def sh(
cmd: str, *, capture: bool = True, check: bool = True
) -> subprocess.CompletedProcess:
return run(["sh", "-lc", cmd], capture=capture, check=check)
def unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:10]}"
def require_docker() -> None:
run(["docker", "version"], capture=True, check=True)
def machine_hash() -> str:
out = sh("sha256sum /etc/machine-id | awk '{print $1}'").stdout.strip()
if len(out) < 16:
raise RuntimeError("Could not determine machine hash from /etc/machine-id")
return out
def wait_for_log(container: str, pattern: str, timeout_s: int = 60) -> None:
deadline = time.time() + timeout_s
while time.time() < deadline:
p = run(["docker", "logs", container], capture=True, check=False)
if pattern in (p.stdout or ""):
return
time.sleep(1)
raise TimeoutError(f"Timed out waiting for log pattern '{pattern}' in {container}")
def wait_for_postgres(
container: str, *, user: str = "postgres", timeout_s: int = 90
) -> None:
"""
Docker-outside-of-Docker friendly readiness: check from inside the DB container.
"""
deadline = time.time() + timeout_s
while time.time() < deadline:
p = run(
[
"docker",
"exec",
container,
"sh",
"-lc",
f"pg_isready -U {user} -h localhost",
],
capture=True,
check=False,
)
if p.returncode == 0:
return
time.sleep(1)
raise TimeoutError(
f"Timed out waiting for Postgres readiness in container {container}"
)
def wait_for_mariadb(
container: str, *, root_password: str, timeout_s: int = 90
) -> None:
"""
Liveness probe for MariaDB.
IMPORTANT (MariaDB 11):
Root TCP auth is often restricted (unix_socket auth), so a TCP ping like
`mariadb-admin -uroot -p... -h localhost ping` can fail even though the server is up.
We therefore check readiness via a socket-based query.
"""
deadline = time.time() + timeout_s
while time.time() < deadline:
p = run(
[
"docker",
"exec",
container,
"sh",
"-lc",
'mariadb -uroot --protocol=socket -e "SELECT 1;"',
],
capture=True,
check=False,
)
if p.returncode == 0:
return
time.sleep(1)
raise TimeoutError(
f"Timed out waiting for MariaDB readiness in container {container}"
)
def wait_for_mariadb_sql(
container: str, *, user: str, password: str, timeout_s: int = 90
) -> None:
"""
SQL login readiness for the *dedicated test user* over TCP.
This is separate from wait_for_mariadb(root) because root may be socket-only,
while the tests use a normal user that should work via TCP.
"""
deadline = time.time() + timeout_s
while time.time() < deadline:
p = run(
[
"docker",
"exec",
container,
"sh",
"-lc",
f'mariadb -h 127.0.0.1 -u{user} -p{password} -e "SELECT 1;"',
],
capture=True,
check=False,
)
if p.returncode == 0:
return
time.sleep(1)
raise TimeoutError(
f"Timed out waiting for MariaDB SQL login readiness in container {container}"
)

View File

@@ -0,0 +1,41 @@
"""Copy a live database's volume out of a snapshot, using the real backup path.
Runs inside the privileged container built by test_e2e_snapshot_db.py, where a
database is mid-write on a btrfs subvolume. Exercises volume_snapshot and
backup_volume exactly as a backup run would.
"""
from __future__ import annotations
import subprocess
import sys
sys.path.insert(0, "/src")
from baudolo.backup.snapshot import SnapshotError, volume_snapshot # noqa: E402
from baudolo.backup.volume import backup_volume # noqa: E402
SUBJECT = "/subject/docker"
VOLUME = "mariadb_data"
DATADIR = f"{SUBJECT}/volumes/{VOLUME}/_data"
VERSIONS = "/backups"
GENERATION = f"{VERSIONS}/20260731"
def shell(command: str) -> list[str]:
proc = subprocess.run(command, shell=True, capture_output=True, text=True)
if proc.returncode != 0:
raise SnapshotError(f"{command} exited {proc.returncode}: {proc.stderr.strip()}")
return proc.stdout.splitlines()
with volume_snapshot("btrfs", SUBJECT, "dbtest", run=shell) as resolve:
backup_volume(
VERSIONS,
VOLUME,
f"{GENERATION}/{VOLUME}",
authoritative=True,
source=resolve(f"{DATADIR}/"),
)
print("SNAPSHOT COPY DONE", flush=True)

View File

@@ -0,0 +1,59 @@
"""Exercise volume_snapshot against a real filesystem, from inside a container.
Runs where loop devices exist. Prints one PASS/FAIL line per assertion and exits
non-zero on the first failure, so the calling test can surface the reason.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, "/src")
from baudolo.backup.snapshot import SnapshotError, volume_snapshot # noqa: E402
KIND = sys.argv[1]
SUBJECT = sys.argv[2]
EXPECT = sys.argv[3]
def shell(command: str) -> list[str]:
proc = subprocess.run(command, shell=True, capture_output=True, text=True)
if proc.returncode != 0:
raise SnapshotError(f"{command} exited {proc.returncode}: {proc.stderr.strip()}")
return proc.stdout.splitlines()
def check(label: str, condition: bool) -> None:
print(f"{'PASS' if condition else 'FAIL'} {label}", flush=True)
if not condition:
sys.exit(1)
volume = Path(SUBJECT) / "volumes" / "demo" / "_data"
volume.mkdir(parents=True, exist_ok=True)
(volume / "state").write_text("before\n")
if EXPECT == "unsupported":
try:
with volume_snapshot(KIND, SUBJECT, "e2e", run=shell):
check("snapshot on an unsupported filesystem must not succeed", False)
except SnapshotError as exc:
check(f"refused loudly: {str(exc)[:60]}", True)
sys.exit(0)
with volume_snapshot(KIND, SUBJECT, "e2e", run=shell) as resolve:
frozen = Path(resolve(str(volume))) / "state"
check("the snapshot exposes the volume", frozen.is_file())
check("the snapshot carries the content", frozen.read_text() == "before\n")
(volume / "state").write_text("after\n")
check("a later write does not reach the snapshot", frozen.read_text() == "before\n")
check("the live tree did change", (volume / "state").read_text() == "after\n")
root = Path(resolve(SUBJECT))
check("the snapshot is removed afterwards", not root.exists() or not (root / "volumes").exists())
print("ALL OK", flush=True)

View File

@@ -0,0 +1,29 @@
import unittest
from .helpers import run
class TestE2ECLIContractDumpOnlySql(unittest.TestCase):
def test_help_mentions_new_flag(self) -> None:
cp = run(["baudolo", "--help"], capture=True, check=True)
out = (cp.stdout or "") + "\n" + (cp.stderr or "")
self.assertIn(
"--dump-only-sql",
out,
f"Expected '--dump-only-sql' to appear in --help output. Output:\n{out}",
)
def test_old_flag_is_rejected(self) -> None:
cp = run(["baudolo", "--dump-only"], capture=True, check=False)
self.assertEqual(
cp.returncode,
2,
f"Expected exitcode 2 for unknown args, got {cp.returncode}\n"
f"STDOUT={cp.stdout}\nSTDERR={cp.stderr}",
)
err = (cp.stderr or "") + "\n" + (cp.stdout or "")
# Argparse typically prints "unrecognized arguments"
self.assertTrue(
("unrecognized arguments" in err) or ("usage:" in err.lower()),
f"Expected argparse-style error output. Output:\n{err}",
)

View File

@@ -0,0 +1,178 @@
# tests/e2e/test_e2e_dump_only_fallback_to_files.py
import unittest
from .helpers import (
POSTGRES_IMAGE,
POSTGRES_DATA_DIR,
backup_path,
cleanup_docker,
create_minimal_compose_dir,
ensure_empty_dir,
latest_version_dir,
require_docker,
run,
unique,
write_databases_csv,
wait_for_postgres,
)
class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
cls.prefix = unique("baudolo-e2e-dump-only-sql-fallback")
cls.backups_dir = f"/tmp/{cls.prefix}/Backups"
ensure_empty_dir(cls.backups_dir)
cls.compose_dir = create_minimal_compose_dir(f"/tmp/{cls.prefix}")
cls.repo_name = cls.prefix
cls.pg_container = f"{cls.prefix}-pg"
cls.pg_volume = f"{cls.prefix}-pg-vol"
cls.restore_volume = f"{cls.prefix}-restore-vol"
cls.containers = [cls.pg_container]
cls.volumes = [cls.pg_volume, cls.restore_volume]
run(["docker", "volume", "create", cls.pg_volume])
# Start Postgres (creates a real DB volume)
run(
[
"docker",
"run",
"-d",
"--name",
cls.pg_container,
"-e",
"POSTGRES_PASSWORD=pgpw",
"-e",
"POSTGRES_DB=appdb",
"-e",
"POSTGRES_USER=postgres",
"-v",
f"{cls.pg_volume}:{POSTGRES_DATA_DIR}",
POSTGRES_IMAGE,
]
)
wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90)
# Add a deterministic marker file into the volume
cls.marker = "dump-only-sql-fallback-marker"
run(
[
"docker",
"exec",
cls.pg_container,
"sh",
"-lc",
f"echo '{cls.marker}' > {POSTGRES_DATA_DIR}/marker.txt",
]
)
# databases.csv WITHOUT matching entry for this instance -> should skip dump
cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv"
write_databases_csv(cls.databases_csv, []) # empty except header
# Run baudolo with --dump-only-sql and a DB container present:
# Expected: WARNING + FALLBACK to file backup (files/ must exist)
cmd = [
"baudolo",
"--compose-dir",
cls.compose_dir,
"--hard-restart-projects",
"mailu",
"--repo-name",
cls.repo_name,
"--databases-csv",
cls.databases_csv,
"--backups-dir",
cls.backups_dir,
"--database-containers",
cls.pg_container,
"--images-no-stop-required",
POSTGRES_IMAGE,
"--dump-only-sql",
]
cp = run(cmd, capture=True, check=True)
cls.stdout = cp.stdout or ""
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
# Restore files into a fresh volume to prove file backup happened
run(["docker", "volume", "create", cls.restore_volume])
run(
[
"baudolo-restore",
"files",
cls.restore_volume,
cls.hash,
cls.version,
"--backups-dir",
cls.backups_dir,
"--repo-name",
cls.repo_name,
"--source-volume",
cls.pg_volume,
]
)
@classmethod
def tearDownClass(cls) -> None:
cleanup_docker(containers=cls.containers, volumes=cls.volumes)
def test_warns_about_missing_dump_in_dump_only_mode(self) -> None:
self.assertIn(
"WARNING: dump-only-sql requested but no DB dump was produced",
self.stdout,
f"Expected warning in baudolo output. STDOUT:\n{self.stdout}",
)
def test_files_backup_exists_due_to_fallback(self) -> None:
p = (
backup_path(
self.backups_dir,
self.repo_name,
self.version,
self.pg_volume,
)
/ "files"
)
self.assertTrue(p.is_dir(), f"Expected files backup dir at: {p}")
def test_sql_dump_not_present(self) -> None:
# There should be no sql dumps because databases.csv had no matching entry.
sql_dir = (
backup_path(
self.backups_dir,
self.repo_name,
self.version,
self.pg_volume,
)
/ "sql"
)
# Could exist (dir created) in some edge cases, but should contain no *.sql dumps.
if sql_dir.exists():
dumps = list(sql_dir.glob("*.sql"))
self.assertEqual(
len(dumps),
0,
f"Did not expect SQL dump files, found: {dumps}",
)
def test_restored_files_contain_marker(self) -> None:
p = run(
[
"docker",
"run",
"--rm",
"-v",
f"{self.restore_volume}:/data",
"alpine:3.20",
"sh",
"-lc",
"cat /data/marker.txt",
]
)
self.assertEqual((p.stdout or "").strip(), self.marker)

View File

@@ -0,0 +1,183 @@
import unittest
from .helpers import (
POSTGRES_IMAGE,
POSTGRES_DATA_DIR,
backup_path,
cleanup_docker,
create_minimal_compose_dir,
ensure_empty_dir,
latest_version_dir,
require_docker,
run,
unique,
wait_for_postgres,
write_databases_csv,
)
class TestE2EDumpOnlySqlMixedRun(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
cls.prefix = unique("baudolo-e2e-dump-only-sql-mixed-run")
cls.backups_dir = f"/tmp/{cls.prefix}/Backups"
ensure_empty_dir(cls.backups_dir)
cls.compose_dir = create_minimal_compose_dir(f"/tmp/{cls.prefix}")
cls.repo_name = cls.prefix
# --- Volumes ---
cls.db_volume = f"{cls.prefix}-vol-db"
cls.files_volume = f"{cls.prefix}-vol-files"
# Track for cleanup
cls.containers: list[str] = []
cls.volumes = [cls.db_volume, cls.files_volume]
# Create volumes
run(["docker", "volume", "create", cls.db_volume])
run(["docker", "volume", "create", cls.files_volume])
# Put a marker into the non-db volume
run(
[
"docker",
"run",
"--rm",
"-v",
f"{cls.files_volume}:/data",
"alpine:3.20",
"sh",
"-lc",
"echo 'hello-non-db' > /data/hello.txt",
]
)
# --- Start Postgres container using the DB volume ---
cls.pg_container = f"{cls.prefix}-pg"
cls.containers.append(cls.pg_container)
cls.pg_password = "postgres"
cls.pg_db = "testdb"
cls.pg_user = "postgres"
run(
[
"docker",
"run",
"-d",
"--name",
cls.pg_container,
"-e",
f"POSTGRES_PASSWORD={cls.pg_password}",
"-v",
f"{cls.db_volume}:{POSTGRES_DATA_DIR}",
POSTGRES_IMAGE,
]
)
wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90)
# Create deterministic content in DB so dump is non-empty
run(
[
"docker",
"exec",
cls.pg_container,
"sh",
"-lc",
f'psql -U postgres -c "CREATE DATABASE {cls.pg_db};" || true',
],
check=True,
)
run(
[
"docker",
"exec",
cls.pg_container,
"sh",
"-lc",
(
f"psql -U postgres -d {cls.pg_db} -c "
'"CREATE TABLE IF NOT EXISTS t (id INT PRIMARY KEY, v TEXT);'
"INSERT INTO t(id,v) VALUES (1,'hello-db') "
'ON CONFLICT (id) DO UPDATE SET v=EXCLUDED.v;"'
),
],
check=True,
)
# databases.csv with an entry => dump should succeed
cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv"
write_databases_csv(
cls.databases_csv,
[(cls.pg_container, cls.pg_db, cls.pg_user, cls.pg_password)],
)
# Run baudolo with dump-only-sql
cmd = [
"baudolo",
"--compose-dir",
cls.compose_dir,
"--databases-csv",
cls.databases_csv,
"--database-containers",
cls.pg_container,
"--images-no-stop-required",
POSTGRES_IMAGE,
"--dump-only-sql",
"--backups-dir",
cls.backups_dir,
"--repo-name",
cls.repo_name,
]
cp = run(cmd, capture=True, check=True)
cls.stdout = cp.stdout
cls.stderr = cp.stderr
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
@classmethod
def tearDownClass(cls) -> None:
cleanup_docker(containers=cls.containers, volumes=cls.volumes)
def test_db_volume_has_dump_and_no_files_dir(self) -> None:
base = backup_path(
self.backups_dir, self.repo_name, self.version, self.db_volume
)
dumps = base / "sql"
files = base / "files"
self.assertTrue(dumps.exists(), f"Expected dumps dir for DB volume at: {dumps}")
self.assertFalse(
files.exists(),
f"Did not expect files dir for DB volume when dump succeeded at: {files}",
)
# Optional: at least one dump file exists
dump_files = list(dumps.glob("*.sql")) + list(dumps.glob("*.sql.gz"))
self.assertTrue(
dump_files,
f"Expected at least one SQL dump file in {dumps}, found none.",
)
def test_non_db_volume_has_files_dir(self) -> None:
base = backup_path(
self.backups_dir, self.repo_name, self.version, self.files_volume
)
files = base / "files"
self.assertTrue(
files.exists(),
f"Expected files dir for non-DB volume at: {files}",
)
def test_dump_only_sql_does_not_disable_non_db_files_backup(self) -> None:
# Regression guard: even with --dump-only-sql, non-DB volumes must still be backed up as files
base = backup_path(
self.backups_dir, self.repo_name, self.version, self.files_volume
)
self.assertTrue(
(base / "files").exists(),
f"Expected non-DB volume files backup to exist at: {base / 'files'}",
)

View File

@@ -0,0 +1,114 @@
import unittest
from .helpers import (
backup_run,
backup_path,
cleanup_docker,
create_minimal_compose_dir,
ensure_empty_dir,
latest_version_dir,
require_docker,
unique,
write_databases_csv,
run,
)
class TestE2EFilesFull(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
cls.prefix = unique("baudolo-e2e-files-full")
cls.backups_dir = f"/tmp/{cls.prefix}/Backups"
ensure_empty_dir(cls.backups_dir)
cls.compose_dir = create_minimal_compose_dir(f"/tmp/{cls.prefix}")
cls.repo_name = cls.prefix
cls.volume_src = f"{cls.prefix}-vol-src"
cls.volume_dst = f"{cls.prefix}-vol-dst"
cls.containers = []
cls.volumes = [cls.volume_src, cls.volume_dst]
# create source volume with a file
run(["docker", "volume", "create", cls.volume_src])
run(
[
"docker",
"run",
"--rm",
"-v",
f"{cls.volume_src}:/data",
"alpine:3.20",
"sh",
"-lc",
"mkdir -p /data && echo 'hello' > /data/hello.txt",
]
)
# databases.csv (unused, but required by CLI)
cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv"
write_databases_csv(cls.databases_csv, [])
# Run backup (files should be copied)
backup_run(
backups_dir=cls.backups_dir,
repo_name=cls.repo_name,
compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv,
database_containers=["dummy-db"],
images_no_stop_required=["alpine:3.20"],
)
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
@classmethod
def tearDownClass(cls) -> None:
cleanup_docker(containers=cls.containers, volumes=cls.volumes)
def test_files_backup_exists(self) -> None:
p = (
backup_path(
self.backups_dir,
self.repo_name,
self.version,
self.volume_src,
)
/ "files"
/ "hello.txt"
)
self.assertTrue(p.is_file(), f"Expected backed up file at: {p}")
def test_restore_files_into_new_volume(self) -> None:
# restore files from volume_src backup into volume_dst
run(
[
"baudolo-restore",
"files",
self.volume_dst,
self.hash,
self.version,
"--backups-dir",
self.backups_dir,
"--repo-name",
self.repo_name,
"--source-volume",
self.volume_src,
]
)
# verify restored file exists in dst volume
p = run(
[
"docker",
"run",
"--rm",
"-v",
f"{self.volume_dst}:/data",
"alpine:3.20",
"sh",
"-lc",
"cat /data/hello.txt",
]
)
self.assertEqual((p.stdout or "").strip(), "hello")

View File

@@ -0,0 +1,119 @@
import unittest
from .helpers import (
backup_run,
backup_path,
cleanup_docker,
create_minimal_compose_dir,
ensure_empty_dir,
latest_version_dir,
require_docker,
unique,
write_databases_csv,
run,
)
class TestE2EFilesNoCopy(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
cls.prefix = unique("baudolo-e2e-files-nocopy")
cls.backups_dir = f"/tmp/{cls.prefix}/Backups"
ensure_empty_dir(cls.backups_dir)
cls.compose_dir = create_minimal_compose_dir(f"/tmp/{cls.prefix}")
cls.repo_name = cls.prefix
cls.volume_src = f"{cls.prefix}-vol-src"
cls.containers: list[str] = []
cls.volumes = [cls.volume_src]
# Create source volume and write a marker file
run(["docker", "volume", "create", cls.volume_src])
run(
[
"docker",
"run",
"--rm",
"-v",
f"{cls.volume_src}:/data",
"alpine:3.20",
"sh",
"-lc",
"echo 'hello' > /data/hello.txt",
]
)
cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv"
write_databases_csv(cls.databases_csv, [])
# dump-only-sql => non-DB volumes are STILL backed up as files
backup_run(
backups_dir=cls.backups_dir,
repo_name=cls.repo_name,
compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv,
database_containers=["dummy-db"],
images_no_stop_required=["alpine:3.20"],
dump_only_sql=True,
)
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
# Wipe the volume to ensure restore actually restores something
run(["docker", "volume", "rm", "-f", cls.volume_src])
run(["docker", "volume", "create", cls.volume_src])
@classmethod
def tearDownClass(cls) -> None:
cleanup_docker(containers=cls.containers, volumes=cls.volumes)
def test_files_backup_present_for_non_db_volume(self) -> None:
p = (
backup_path(self.backups_dir, self.repo_name, self.version, self.volume_src)
/ "files"
)
self.assertTrue(p.exists(), f"Expected files backup dir at: {p}")
def test_restore_files_succeeds_and_restores_content(self) -> None:
p = run(
[
"baudolo-restore",
"files",
self.volume_src,
self.hash,
self.version,
"--backups-dir",
self.backups_dir,
"--repo-name",
self.repo_name,
],
check=False,
)
self.assertEqual(
p.returncode,
0,
f"Expected exitcode 0, got {p.returncode}\nSTDOUT={p.stdout}\nSTDERR={p.stderr}",
)
cp = run(
[
"docker",
"run",
"--rm",
"-v",
f"{self.volume_src}:/data",
"alpine:3.20",
"sh",
"-lc",
"cat /data/hello.txt",
],
capture=True,
check=True,
)
self.assertEqual(
cp.stdout.strip(),
"hello",
f"Unexpected restored content. STDOUT={cp.stdout}\nSTDERR={cp.stderr}",
)

View File

@@ -0,0 +1,127 @@
# tests/e2e/test_e2e_images_no_backup_required_early_skip.py
import unittest
from .helpers import (
backup_path,
cleanup_docker,
create_minimal_compose_dir,
ensure_empty_dir,
latest_version_dir,
require_docker,
run,
unique,
write_databases_csv,
)
class TestE2EImagesNoBackupRequiredEarlySkip(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
cls.prefix = unique("baudolo-e2e-early-skip-no-backup-required")
cls.backups_dir = f"/tmp/{cls.prefix}/Backups"
ensure_empty_dir(cls.backups_dir)
cls.compose_dir = create_minimal_compose_dir(f"/tmp/{cls.prefix}")
cls.repo_name = cls.prefix
# --- Docker resources ---
cls.redis_container = f"{cls.prefix}-redis"
cls.ignored_volume = f"{cls.prefix}-redis-vol"
cls.normal_volume = f"{cls.prefix}-files-vol"
cls.containers = [cls.redis_container]
cls.volumes = [cls.ignored_volume, cls.normal_volume]
# Create volumes
run(["docker", "volume", "create", cls.ignored_volume])
run(["docker", "volume", "create", cls.normal_volume])
# Start redis container using the ignored volume
run(
[
"docker",
"run",
"-d",
"--name",
cls.redis_container,
"-v",
f"{cls.ignored_volume}:/data",
"redis:alpine",
]
)
# Put deterministic content into the normal volume
run(
[
"docker",
"run",
"--rm",
"-v",
f"{cls.normal_volume}:/data",
"alpine:3.20",
"sh",
"-lc",
"mkdir -p /data && echo 'hello' > /data/hello.txt",
]
)
# databases.csv required by CLI (can be empty)
cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv"
write_databases_csv(cls.databases_csv, [])
# Run baudolo with images-no-backup-required redis
cmd = [
"baudolo",
"--compose-dir",
cls.compose_dir,
"--hard-restart-projects",
"mailu",
"--repo-name",
cls.repo_name,
"--databases-csv",
cls.databases_csv,
"--backups-dir",
cls.backups_dir,
"--database-containers",
"dummy-db",
"--images-no-stop-required",
"redis:alpine",
"--images-no-backup-required",
"redis:alpine",
]
cp = run(cmd, capture=True, check=True)
cls.stdout = cp.stdout or ""
cls.stderr = cp.stderr or ""
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
@classmethod
def tearDownClass(cls) -> None:
cleanup_docker(containers=cls.containers, volumes=cls.volumes)
def test_ignored_volume_has_no_backup_directory_at_all(self) -> None:
p = backup_path(
self.backups_dir,
self.repo_name,
self.version,
self.ignored_volume,
)
self.assertFalse(
p.exists(),
f"Expected NO backup directory to be created for ignored volume, but found: {p}",
)
def test_normal_volume_is_still_backed_up(self) -> None:
p = (
backup_path(
self.backups_dir,
self.repo_name,
self.version,
self.normal_volume,
)
/ "files"
/ "hello.txt"
)
self.assertTrue(p.is_file(), f"Expected backed up file at: {p}")

View File

@@ -0,0 +1,164 @@
"""
Bug-repro for: mariadb-dump fails with `ERROR 1045 Access denied for user
'<u>'@'localhost' (using password: YES)` when only '<u>'@'%' is granted and a
preempting ''@'localhost' user is present.
The fix forces TCP loopback in baudolo.backup.db so the dump matches the
'<u>'@'%' grant instead of the socket->localhost auth row.
This file:
- builds the exact preconditions that triggered the production failure,
- as a NEGATIVE control, runs a socket-based mariadb-dump (== the old code path)
and asserts that it fails with the literal 1045 / @'localhost' error,
- as a POSITIVE proof, calls backup_database() (where the fix lives) against
the same DB container and asserts the dump file is produced and contains the
seed data.
Note: the volume-rsync stage of baudolo is intentionally NOT exercised here.
That stage needs root on /var/lib/docker/volumes, which is provided by the
DinD wrapper in `make test-e2e` but not by an on-host invocation. The bug we
are verifying is in the DB-dump stage, so testing backup_database() directly
keeps the assertion focused and the test runnable both on-host and in DinD.
"""
import os
import tempfile
import unittest
import pandas
from baudolo.backup import db as db_mod
from .helpers import (
MARIADB_IMAGE,
MARIADB_DATA_DIR,
cleanup_docker,
require_docker,
run,
unique,
wait_for_mariadb,
wait_for_mariadb_sql,
)
class TestE2EMariaDBAnonymousPreemption(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
cls.prefix = unique("baudolo-e2e-mariadb-anon")
cls.db_container = f"{cls.prefix}-mariadb"
cls.db_volume = f"{cls.prefix}-mariadb-vol"
cls.containers = [cls.db_container]
cls.volumes = [cls.db_volume]
cls.db_name = "appdb"
cls.db_user = "tcponly"
cls.db_password = "tcponlypw"
cls.root_password = "rootpw"
run(["docker", "volume", "create", cls.db_volume])
# Boot WITHOUT MARIADB_USER/MARIADB_PASSWORD/MARIADB_DATABASE so the
# entrypoint does not auto-create '<u>'@'%'. We provision the user
# explicitly below to mirror the SQL path used by svc-db-mariadb.
run(
[
"docker",
"run",
"-d",
"--name",
cls.db_container,
"-e",
f"MARIADB_ROOT_PASSWORD={cls.root_password}",
"-v",
f"{cls.db_volume}:{MARIADB_DATA_DIR}",
MARIADB_IMAGE,
]
)
wait_for_mariadb(
cls.db_container, root_password=cls.root_password, timeout_s=120
)
# Provision: '<u>'@'%' (the app/backup grant) + anonymous ''@'localhost'
# (the preemption trigger). Mirrors the production state that produced
# `ERROR 1045 ... '<u>'@'localhost' (using password: YES)`.
bootstrap_sql = (
f"CREATE DATABASE {cls.db_name};"
f"CREATE USER '{cls.db_user}'@'%' IDENTIFIED BY '{cls.db_password}';"
f"GRANT ALL PRIVILEGES ON {cls.db_name}.* TO '{cls.db_user}'@'%';"
f"CREATE USER ''@'localhost' IDENTIFIED BY 'anonpw-not-{cls.db_password}';"
"FLUSH PRIVILEGES;"
f"CREATE TABLE {cls.db_name}.t (id INT PRIMARY KEY, v VARCHAR(50));"
f"INSERT INTO {cls.db_name}.t VALUES (1,'ok');"
)
run(
[
"docker",
"exec",
cls.db_container,
"sh",
"-lc",
f'mariadb -uroot --protocol=socket -e "{bootstrap_sql}"',
]
)
# Sanity: '<u>' can log in over TCP (matches '%'). If THIS fails,
# the precondition for the fix to even apply is broken.
wait_for_mariadb_sql(
cls.db_container, user=cls.db_user, password=cls.db_password, timeout_s=60
)
@classmethod
def tearDownClass(cls) -> None:
cleanup_docker(containers=cls.containers, volumes=cls.volumes)
def test_negative_control_socket_dump_fails_with_1045(self) -> None:
# Reproduces the OLD code path (no -h/--protocol). MUST fail with 1045
# under the configured preemption. If this ever starts passing, either
# the MariaDB auth semantics changed or the anonymous-user setup did
# not take effect — in both cases the positive test below loses its
# ability to discriminate "fix works" vs "bug never reproduced".
p = run(
[
"docker",
"exec",
self.db_container,
"sh",
"-lc",
f"mariadb-dump -u{self.db_user} -p{self.db_password} {self.db_name}",
],
capture=True,
check=False,
)
self.assertNotEqual(p.returncode, 0, "socket-based dump unexpectedly succeeded")
self.assertIn("1045", (p.stderr or "") + (p.stdout or ""))
self.assertIn("@'localhost'", (p.stderr or "") + (p.stdout or ""))
def test_backup_database_succeeds_with_tcp_fix(self) -> None:
# Drives the function where the fix lives. No rsync, no privileged
# paths — just the dump that the negative-control proved is failing
# under the same preemption setup.
with tempfile.TemporaryDirectory() as volume_dir:
df = pandas.DataFrame(
[(self.db_container, self.db_name, self.db_user, self.db_password)],
columns=["instance", "database", "username", "password"],
)
produced = db_mod.backup_database(
container=self.db_container,
volume_dir=volume_dir,
db_type="mariadb",
databases_df=df,
database_containers=[self.db_container],
)
self.assertTrue(produced, "backup_database did not produce a dump")
dump_path = os.path.join(volume_dir, "sql", f"{self.db_name}.backup.sql")
self.assertTrue(os.path.isfile(dump_path), f"expected dump at {dump_path}")
with open(dump_path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
self.assertIn("INSERT INTO", content)
self.assertIn("'ok'", content)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -0,0 +1,168 @@
# tests/e2e/test_e2e_mariadb_full.py
import unittest
from .helpers import (
MARIADB_IMAGE,
MARIADB_DATA_DIR,
backup_run,
backup_path,
cleanup_docker,
create_minimal_compose_dir,
ensure_empty_dir,
latest_version_dir,
require_docker,
unique,
write_databases_csv,
run,
wait_for_mariadb,
wait_for_mariadb_sql,
)
class TestE2EMariaDBFull(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
cls.prefix = unique("baudolo-e2e-mariadb-full")
cls.backups_dir = f"/tmp/{cls.prefix}/Backups"
ensure_empty_dir(cls.backups_dir)
cls.compose_dir = create_minimal_compose_dir(f"/tmp/{cls.prefix}")
cls.repo_name = cls.prefix
cls.db_container = f"{cls.prefix}-mariadb"
cls.db_volume = f"{cls.prefix}-mariadb-vol"
cls.containers = [cls.db_container]
cls.volumes = [cls.db_volume]
cls.db_name = "appdb"
cls.db_user = "test"
cls.db_password = "testpw"
cls.root_password = "rootpw"
run(["docker", "volume", "create", cls.db_volume])
# Start MariaDB with a dedicated TCP-capable user for tests.
run(
[
"docker",
"run",
"-d",
"--name",
cls.db_container,
"-e",
f"MARIADB_ROOT_PASSWORD={cls.root_password}",
"-e",
f"MARIADB_DATABASE={cls.db_name}",
"-e",
f"MARIADB_USER={cls.db_user}",
"-e",
f"MARIADB_PASSWORD={cls.db_password}",
"-v",
f"{cls.db_volume}:{MARIADB_DATA_DIR}",
MARIADB_IMAGE,
]
)
# Liveness + actual SQL login readiness (TCP)
wait_for_mariadb(
cls.db_container, root_password=cls.root_password, timeout_s=90
)
wait_for_mariadb_sql(
cls.db_container, user=cls.db_user, password=cls.db_password, timeout_s=90
)
# Create table + data via the dedicated user (TCP)
run(
[
"docker",
"exec",
cls.db_container,
"sh",
"-lc",
f"mariadb -h 127.0.0.1 -u{cls.db_user} -p{cls.db_password} "
f'-e "CREATE TABLE {cls.db_name}.t (id INT PRIMARY KEY, v VARCHAR(50)); '
f"INSERT INTO {cls.db_name}.t VALUES (1,'ok');\"",
]
)
cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv"
# IMPORTANT: baudolo backup expects credentials for the DB dump.
write_databases_csv(
cls.databases_csv,
[(cls.db_container, cls.db_name, cls.db_user, cls.db_password)],
)
# Backup with file+dump
backup_run(
backups_dir=cls.backups_dir,
repo_name=cls.repo_name,
compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv,
database_containers=[cls.db_container],
images_no_stop_required=[MARIADB_IMAGE],
)
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
# Wipe DB via the dedicated user (TCP)
run(
[
"docker",
"exec",
cls.db_container,
"sh",
"-lc",
f"mariadb -h 127.0.0.1 -u{cls.db_user} -p{cls.db_password} "
f'-e "DROP TABLE {cls.db_name}.t;"',
]
)
# Restore DB (uses baudolo-restore which execs mysql/mariadb inside the container)
run(
[
"baudolo-restore",
"mariadb",
cls.db_volume,
cls.hash,
cls.version,
"--backups-dir",
cls.backups_dir,
"--repo-name",
cls.repo_name,
"--container",
cls.db_container,
"--db-name",
cls.db_name,
"--db-user",
cls.db_user,
"--db-password",
cls.db_password,
"--empty",
]
)
@classmethod
def tearDownClass(cls) -> None:
cleanup_docker(containers=cls.containers, volumes=cls.volumes)
def test_dump_file_exists(self) -> None:
p = (
backup_path(self.backups_dir, self.repo_name, self.version, self.db_volume)
/ "sql"
/ f"{self.db_name}.backup.sql"
)
self.assertTrue(p.is_file(), f"Expected dump file at: {p}")
def test_data_restored(self) -> None:
p = run(
[
"docker",
"exec",
self.db_container,
"sh",
"-lc",
f"mariadb -h 127.0.0.1 -u{self.db_user} -p{self.db_password} "
f'-N -e "SELECT v FROM {self.db_name}.t WHERE id=1;"',
]
)
self.assertEqual((p.stdout or "").strip(), "ok")

View File

@@ -0,0 +1,165 @@
# tests/e2e/test_e2e_mariadb_no_copy.py
import unittest
from .helpers import (
MARIADB_IMAGE,
MARIADB_DATA_DIR,
backup_run,
backup_path,
cleanup_docker,
create_minimal_compose_dir,
ensure_empty_dir,
latest_version_dir,
require_docker,
unique,
write_databases_csv,
run,
wait_for_mariadb,
wait_for_mariadb_sql,
)
class TestE2EMariaDBNoCopy(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
cls.prefix = unique("baudolo-e2e-mariadb-nocopy")
cls.backups_dir = f"/tmp/{cls.prefix}/Backups"
ensure_empty_dir(cls.backups_dir)
cls.compose_dir = create_minimal_compose_dir(f"/tmp/{cls.prefix}")
cls.repo_name = cls.prefix
cls.db_container = f"{cls.prefix}-mariadb"
cls.db_volume = f"{cls.prefix}-mariadb-vol"
cls.containers = [cls.db_container]
cls.volumes = [cls.db_volume]
cls.db_name = "appdb"
cls.db_user = "test"
cls.db_password = "testpw"
cls.root_password = "rootpw"
run(["docker", "volume", "create", cls.db_volume])
run(
[
"docker",
"run",
"-d",
"--name",
cls.db_container,
"-e",
f"MARIADB_ROOT_PASSWORD={cls.root_password}",
"-e",
f"MARIADB_DATABASE={cls.db_name}",
"-e",
f"MARIADB_USER={cls.db_user}",
"-e",
f"MARIADB_PASSWORD={cls.db_password}",
"-v",
f"{cls.db_volume}:{MARIADB_DATA_DIR}",
MARIADB_IMAGE,
]
)
wait_for_mariadb(
cls.db_container, root_password=cls.root_password, timeout_s=90
)
wait_for_mariadb_sql(
cls.db_container, user=cls.db_user, password=cls.db_password, timeout_s=90
)
# Create table + data (TCP)
run(
[
"docker",
"exec",
cls.db_container,
"sh",
"-lc",
f"mariadb -h 127.0.0.1 -u{cls.db_user} -p{cls.db_password} "
f'-e "CREATE TABLE {cls.db_name}.t (id INT PRIMARY KEY, v VARCHAR(50)); '
f"INSERT INTO {cls.db_name}.t VALUES (1,'ok');\"",
]
)
cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv"
write_databases_csv(
cls.databases_csv,
[(cls.db_container, cls.db_name, cls.db_user, cls.db_password)],
)
# dump-only-sql => no files
backup_run(
backups_dir=cls.backups_dir,
repo_name=cls.repo_name,
compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv,
database_containers=[cls.db_container],
images_no_stop_required=[MARIADB_IMAGE],
dump_only_sql=True,
)
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
# Wipe table (TCP)
run(
[
"docker",
"exec",
cls.db_container,
"sh",
"-lc",
f"mariadb -h 127.0.0.1 -u{cls.db_user} -p{cls.db_password} "
f'-e "DROP TABLE {cls.db_name}.t;"',
]
)
# Restore DB
run(
[
"baudolo-restore",
"mariadb",
cls.db_volume,
cls.hash,
cls.version,
"--backups-dir",
cls.backups_dir,
"--repo-name",
cls.repo_name,
"--container",
cls.db_container,
"--db-name",
cls.db_name,
"--db-user",
cls.db_user,
"--db-password",
cls.db_password,
"--empty",
]
)
@classmethod
def tearDownClass(cls) -> None:
cleanup_docker(containers=cls.containers, volumes=cls.volumes)
def test_files_backup_not_present(self) -> None:
p = (
backup_path(self.backups_dir, self.repo_name, self.version, self.db_volume)
/ "files"
)
self.assertFalse(p.exists(), f"Did not expect files backup dir at: {p}")
def test_data_restored(self) -> None:
p = run(
[
"docker",
"exec",
self.db_container,
"sh",
"-lc",
f"mariadb -h 127.0.0.1 -u{self.db_user} -p{self.db_password} "
f'-N -e "SELECT v FROM {self.db_name}.t WHERE id=1;"',
]
)
self.assertEqual((p.stdout or "").strip(), "ok")

View File

@@ -0,0 +1,186 @@
# tests/e2e/test_e2e_postgres_empty_drop_hard.py
import unittest
from .helpers import (
POSTGRES_IMAGE,
POSTGRES_DATA_DIR,
backup_run,
cleanup_docker,
create_minimal_compose_dir,
ensure_empty_dir,
latest_version_dir,
require_docker,
run,
unique,
wait_for_postgres,
write_databases_csv,
)
# The scenario the --empty pre-clean must survive: a non-public user schema
# plus one object of every class the discovery SELECT enumerates. Restore
# --empty runs against the still-populated DB (no wipe), so the pre-clean must
# drop discourse_functions too or the dump's CREATE SCHEMA aborts the replay
# under ON_ERROR_STOP; the old public-only DROP left it and broke discourse.
# The f()/f(int) pair reproduces discourse's overload abort ("function name
# is not unique") and english_stem_nostop reproduces taiga's text search
# dictionary abort (duplicate pg_ts_dict_dictname_index).
SCENARIO_SQL = (
"CREATE SCHEMA discourse_functions;"
"CREATE TABLE discourse_functions.helper (id int);"
"INSERT INTO discourse_functions.helper VALUES (1);"
"CREATE TABLE public.t (id int primary key, v text);"
"INSERT INTO public.t VALUES (1, 'ok');"
"CREATE VIEW public.t_view AS SELECT * FROM public.t;"
"CREATE SEQUENCE public.s;"
"CREATE TYPE public.mood AS ENUM ('ok', 'bad');"
"CREATE FUNCTION public.f() RETURNS int LANGUAGE sql AS 'SELECT 1';"
"CREATE FUNCTION public.f(i int) RETURNS int LANGUAGE sql AS 'SELECT i';"
"CREATE COLLATION public.c (locale = 'C');"
"CREATE TEXT SEARCH DICTIONARY public.english_stem_nostop"
" (Template = snowball, Language = english);"
"CREATE TEXT SEARCH CONFIGURATION public.english_nostop (COPY = english);"
"ALTER TEXT SEARCH CONFIGURATION public.english_nostop"
" ALTER MAPPING FOR asciiword WITH public.english_stem_nostop;"
)
class TestE2EPostgresEmptyDropHard(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
cls.prefix = unique("baudolo-e2e-postgres-empty-drop-hard")
cls.backups_dir = f"/tmp/{cls.prefix}/Backups"
ensure_empty_dir(cls.backups_dir)
cls.compose_dir = create_minimal_compose_dir(f"/tmp/{cls.prefix}")
cls.repo_name = cls.prefix
cls.pg_container = f"{cls.prefix}-pg"
cls.pg_volume = f"{cls.prefix}-pg-vol"
cls.containers = [cls.pg_container]
cls.volumes = [cls.pg_volume]
run(["docker", "volume", "create", cls.pg_volume])
run(
[
"docker",
"run",
"-d",
"--name",
cls.pg_container,
"-e",
"POSTGRES_PASSWORD=pgpw",
"-e",
"POSTGRES_DB=appdb",
"-e",
"POSTGRES_USER=postgres",
"-v",
f"{cls.pg_volume}:{POSTGRES_DATA_DIR}",
POSTGRES_IMAGE,
]
)
wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90)
run(
[
"docker",
"exec",
cls.pg_container,
"sh",
"-lc",
f'psql -U postgres -d appdb -v ON_ERROR_STOP=1 -c "{SCENARIO_SQL}"',
]
)
cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv"
write_databases_csv(
cls.databases_csv, [(cls.pg_container, "appdb", "postgres", "pgpw")]
)
backup_run(
backups_dir=cls.backups_dir,
repo_name=cls.repo_name,
compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv,
database_containers=[cls.pg_container],
images_no_stop_required=[POSTGRES_IMAGE],
)
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
# No wipe: restore --empty must pre-clean the fully-populated DB
# (incl. the non-public schema) before replaying the dump.
run(
[
"baudolo-restore",
"postgres",
cls.pg_volume,
cls.hash,
cls.version,
"--backups-dir",
cls.backups_dir,
"--repo-name",
cls.repo_name,
"--container",
cls.pg_container,
"--db-name",
"appdb",
"--db-user",
"postgres",
"--db-password",
"pgpw",
"--empty",
]
)
@classmethod
def tearDownClass(cls) -> None:
cleanup_docker(containers=cls.containers, volumes=cls.volumes)
def _scalar(self, sql: str) -> str:
p = run(
[
"docker",
"exec",
self.pg_container,
"sh",
"-lc",
f'psql -U postgres -d appdb -t -A -c "{sql}"',
]
)
return (p.stdout or "").strip()
def test_public_data_restored(self) -> None:
self.assertEqual(self._scalar("SELECT v FROM public.t WHERE id=1;"), "ok")
def test_view_restored(self) -> None:
self.assertEqual(self._scalar("SELECT count(*) FROM public.t_view;"), "1")
def test_non_public_schema_restored(self) -> None:
self.assertEqual(
self._scalar(
"SELECT count(*) FROM pg_namespace WHERE nspname='discourse_functions';"
),
"1",
)
def test_overloaded_functions_restored_once_each(self) -> None:
self.assertEqual(
self._scalar("SELECT count(*) FROM pg_proc WHERE proname='f';"), "2"
)
self.assertEqual(self._scalar("SELECT public.f(41) + public.f();"), "42")
def test_text_search_dictionary_restored_once(self) -> None:
self.assertEqual(
self._scalar(
"SELECT count(*) FROM pg_ts_dict WHERE dictname='english_stem_nostop';"
),
"1",
)
self.assertEqual(
self._scalar(
"SELECT count(*) FROM pg_ts_config WHERE cfgname='english_nostop';"
),
"1",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,145 @@
# tests/e2e/test_e2e_postgres_full.py
import unittest
from .helpers import (
POSTGRES_IMAGE,
POSTGRES_DATA_DIR,
backup_run,
backup_path,
cleanup_docker,
create_minimal_compose_dir,
ensure_empty_dir,
latest_version_dir,
require_docker,
unique,
write_databases_csv,
run,
wait_for_postgres,
)
class TestE2EPostgresFull(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
cls.prefix = unique("baudolo-e2e-postgres-full")
cls.backups_dir = f"/tmp/{cls.prefix}/Backups"
ensure_empty_dir(cls.backups_dir)
cls.compose_dir = create_minimal_compose_dir(f"/tmp/{cls.prefix}")
cls.repo_name = cls.prefix
cls.pg_container = f"{cls.prefix}-pg"
cls.pg_volume = f"{cls.prefix}-pg-vol"
cls.containers = [cls.pg_container]
cls.volumes = [cls.pg_volume]
run(["docker", "volume", "create", cls.pg_volume])
run(
[
"docker",
"run",
"-d",
"--name",
cls.pg_container,
"-e",
"POSTGRES_PASSWORD=pgpw",
"-e",
"POSTGRES_DB=appdb",
"-e",
"POSTGRES_USER=postgres",
"-v",
f"{cls.pg_volume}:{POSTGRES_DATA_DIR}",
POSTGRES_IMAGE,
]
)
wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90)
# Create a table + data
run(
[
"docker",
"exec",
cls.pg_container,
"sh",
"-lc",
"psql -U postgres -d appdb -c \"CREATE TABLE t (id int primary key, v text); INSERT INTO t VALUES (1,'ok');\"",
]
)
cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv"
write_databases_csv(
cls.databases_csv, [(cls.pg_container, "appdb", "postgres", "pgpw")]
)
backup_run(
backups_dir=cls.backups_dir,
repo_name=cls.repo_name,
compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv,
database_containers=[cls.pg_container],
images_no_stop_required=[POSTGRES_IMAGE],
)
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
# Wipe schema
run(
[
"docker",
"exec",
cls.pg_container,
"sh",
"-lc",
'psql -U postgres -d appdb -c "DROP TABLE t;"',
]
)
# Restore
run(
[
"baudolo-restore",
"postgres",
cls.pg_volume,
cls.hash,
cls.version,
"--backups-dir",
cls.backups_dir,
"--repo-name",
cls.repo_name,
"--container",
cls.pg_container,
"--db-name",
"appdb",
"--db-user",
"postgres",
"--db-password",
"pgpw",
"--empty",
]
)
@classmethod
def tearDownClass(cls) -> None:
cleanup_docker(containers=cls.containers, volumes=cls.volumes)
def test_dump_file_exists(self) -> None:
p = (
backup_path(self.backups_dir, self.repo_name, self.version, self.pg_volume)
/ "sql"
/ "appdb.backup.sql"
)
self.assertTrue(p.is_file(), f"Expected dump file at: {p}")
def test_data_restored(self) -> None:
p = run(
[
"docker",
"exec",
self.pg_container,
"sh",
"-lc",
'psql -U postgres -d appdb -t -c "SELECT v FROM t WHERE id=1;"',
]
)
self.assertEqual((p.stdout or "").strip(), "ok")

View File

@@ -0,0 +1,141 @@
# tests/e2e/test_e2e_postgres_no_copy.py
import unittest
from .helpers import (
POSTGRES_IMAGE,
POSTGRES_DATA_DIR,
backup_run,
backup_path,
cleanup_docker,
create_minimal_compose_dir,
ensure_empty_dir,
latest_version_dir,
require_docker,
unique,
write_databases_csv,
run,
wait_for_postgres,
)
class TestE2EPostgresNoCopy(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
cls.prefix = unique("baudolo-e2e-postgres-nocopy")
cls.backups_dir = f"/tmp/{cls.prefix}/Backups"
ensure_empty_dir(cls.backups_dir)
cls.compose_dir = create_minimal_compose_dir(f"/tmp/{cls.prefix}")
cls.repo_name = cls.prefix
cls.pg_container = f"{cls.prefix}-pg"
cls.pg_volume = f"{cls.prefix}-pg-vol"
cls.containers = [cls.pg_container]
cls.volumes = [cls.pg_volume]
run(["docker", "volume", "create", cls.pg_volume])
run(
[
"docker",
"run",
"-d",
"--name",
cls.pg_container,
"-e",
"POSTGRES_PASSWORD=pgpw",
"-e",
"POSTGRES_DB=appdb",
"-e",
"POSTGRES_USER=postgres",
"-v",
f"{cls.pg_volume}:{POSTGRES_DATA_DIR}",
POSTGRES_IMAGE,
]
)
wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90)
run(
[
"docker",
"exec",
cls.pg_container,
"sh",
"-lc",
"psql -U postgres -d appdb -c \"CREATE TABLE t (id int primary key, v text); INSERT INTO t VALUES (1,'ok');\"",
]
)
cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv"
write_databases_csv(
cls.databases_csv, [(cls.pg_container, "appdb", "postgres", "pgpw")]
)
backup_run(
backups_dir=cls.backups_dir,
repo_name=cls.repo_name,
compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv,
database_containers=[cls.pg_container],
images_no_stop_required=[POSTGRES_IMAGE],
dump_only_sql=True,
)
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
run(
[
"docker",
"exec",
cls.pg_container,
"sh",
"-lc",
'psql -U postgres -d appdb -c "DROP TABLE t;"',
]
)
run(
[
"baudolo-restore",
"postgres",
cls.pg_volume,
cls.hash,
cls.version,
"--backups-dir",
cls.backups_dir,
"--repo-name",
cls.repo_name,
"--container",
cls.pg_container,
"--db-name",
"appdb",
"--db-user",
"postgres",
"--db-password",
"pgpw",
"--empty",
]
)
@classmethod
def tearDownClass(cls) -> None:
cleanup_docker(containers=cls.containers, volumes=cls.volumes)
def test_files_backup_not_present(self) -> None:
p = (
backup_path(self.backups_dir, self.repo_name, self.version, self.pg_volume)
/ "files"
)
self.assertFalse(p.exists(), f"Did not expect files backup dir at: {p}")
def test_data_restored(self) -> None:
p = run(
[
"docker",
"exec",
self.pg_container,
"sh",
"-lc",
'psql -U postgres -d appdb -t -c "SELECT v FROM t WHERE id=1;"',
]
)
self.assertEqual((p.stdout or "").strip(), "ok")

View File

@@ -0,0 +1,185 @@
# tests/e2e/test_e2e_postgres_single_transaction_live_writer.py
import unittest
from .helpers import (
POSTGRES_IMAGE,
POSTGRES_DATA_DIR,
backup_run,
cleanup_docker,
create_minimal_compose_dir,
ensure_empty_dir,
latest_version_dir,
require_docker,
run,
unique,
wait_for_postgres,
write_databases_csv,
)
# The discourse restore-drill race: `restore --empty` replays the dump into a
# LIVE database while a background writer keeps touching a primary-key row
# (discourse's mini_scheduler upserts scheduler_stats(id=1)). Without a
# single-transaction replay, the pre-clean drops the table, the replay recreates
# it and auto-commits, the writer wins the gap and inserts id=1, and the dump's
# COPY of the same id then aborts with a duplicate-key violation under
# ON_ERROR_STOP -> the whole restore fails. The --single-transaction replay keeps
# the recreated table invisible until commit, so the writer can never insert the
# racing row and the restore completes. A wide filler table makes the COPY slow
# enough that the non-transactional variant loses the race deterministically.
SEED_SQL = (
"CREATE TABLE public.scheduler_stats (id int primary key, v text);"
"INSERT INTO public.scheduler_stats VALUES (1, 'from-dump');"
"CREATE TABLE public.filler (id serial primary key, blob text);"
"INSERT INTO public.filler (blob)"
" SELECT repeat('x', 512) FROM generate_series(1, 100000);"
)
WRITER_LOOP = (
"while true; do "
"psql -h 127.0.0.1 -U postgres -d appdb "
"-c \"INSERT INTO public.scheduler_stats(id, v) VALUES (1, 'live') "
'ON CONFLICT (id) DO NOTHING;" >/dev/null 2>&1; '
"done"
)
class TestE2EPostgresSingleTransactionLiveWriter(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
cls.prefix = unique("baudolo-e2e-pg-single-txn")
cls.backups_dir = f"/tmp/{cls.prefix}/Backups"
ensure_empty_dir(cls.backups_dir)
cls.compose_dir = create_minimal_compose_dir(f"/tmp/{cls.prefix}")
cls.repo_name = cls.prefix
cls.pg_container = f"{cls.prefix}-pg"
cls.pg_volume = f"{cls.prefix}-pg-vol"
cls.writer = f"{cls.prefix}-writer"
cls.containers = [cls.pg_container, cls.writer]
cls.volumes = [cls.pg_volume]
run(["docker", "volume", "create", cls.pg_volume])
run(
[
"docker",
"run",
"-d",
"--name",
cls.pg_container,
"-e",
"POSTGRES_PASSWORD=pgpw",
"-e",
"POSTGRES_DB=appdb",
"-e",
"POSTGRES_USER=postgres",
"-v",
f"{cls.pg_volume}:{POSTGRES_DATA_DIR}",
POSTGRES_IMAGE,
]
)
wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90)
run(
[
"docker",
"exec",
cls.pg_container,
"sh",
"-lc",
f'psql -U postgres -d appdb -v ON_ERROR_STOP=1 -c "{SEED_SQL}"',
]
)
cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv"
write_databases_csv(
cls.databases_csv, [(cls.pg_container, "appdb", "postgres", "pgpw")]
)
backup_run(
backups_dir=cls.backups_dir,
repo_name=cls.repo_name,
compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv,
database_containers=[cls.pg_container],
images_no_stop_required=[POSTGRES_IMAGE],
)
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
run(
[
"docker",
"run",
"-d",
"--name",
cls.writer,
"--network",
f"container:{cls.pg_container}",
"-e",
"PGPASSWORD=pgpw",
POSTGRES_IMAGE,
"sh",
"-lc",
WRITER_LOOP,
]
)
cls.restore = run(
[
"baudolo-restore",
"postgres",
cls.pg_volume,
cls.hash,
cls.version,
"--backups-dir",
cls.backups_dir,
"--repo-name",
cls.repo_name,
"--container",
cls.pg_container,
"--db-name",
"appdb",
"--db-user",
"postgres",
"--db-password",
"pgpw",
"--empty",
],
capture=True,
check=False,
)
run(["docker", "rm", "-f", cls.writer], capture=True, check=False)
@classmethod
def tearDownClass(cls) -> None:
cleanup_docker(containers=cls.containers, volumes=cls.volumes)
def _scalar(self, sql: str) -> str:
p = run(
[
"docker",
"exec",
self.pg_container,
"sh",
"-lc",
f'psql -U postgres -d appdb -t -A -c "{sql}"',
]
)
return (p.stdout or "").strip()
def test_restore_survived_the_live_writer(self) -> None:
self.assertEqual(
self.restore.returncode,
0,
f"restore aborted (duplicate-key race not contained):\n{self.restore.stderr}",
)
def test_primary_key_row_restored(self) -> None:
self.assertEqual(
self._scalar("SELECT count(*) FROM public.scheduler_stats WHERE id=1;"),
"1",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,231 @@
import unittest
from .helpers import (
POSTGRES_IMAGE,
POSTGRES_DATA_DIR,
backup_path,
cleanup_docker,
create_minimal_compose_dir,
ensure_empty_dir,
latest_version_dir,
require_docker,
run,
unique,
wait_for_postgres,
)
class TestE2ESeedStarAndDbEntriesBackupPostgres(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
cls.prefix = unique("baudolo-e2e-seed-star-and-db")
cls.backups_dir = f"/tmp/{cls.prefix}/Backups"
ensure_empty_dir(cls.backups_dir)
cls.compose_dir = create_minimal_compose_dir(f"/tmp/{cls.prefix}")
cls.repo_name = cls.prefix
# --- Volumes ---
cls.db_volume = f"{cls.prefix}-vol-db"
cls.files_volume = f"{cls.prefix}-vol-files"
cls.volumes = [cls.db_volume, cls.files_volume]
run(["docker", "volume", "create", cls.db_volume])
run(["docker", "volume", "create", cls.files_volume])
# Put a marker into the non-db volume
cls.marker = "hello-non-db-seed-star"
run(
[
"docker",
"run",
"--rm",
"-v",
f"{cls.files_volume}:/data",
"alpine:3.20",
"sh",
"-lc",
f"echo '{cls.marker}' > /data/hello.txt",
]
)
# --- Start Postgres container using the DB volume ---
cls.pg_container = f"{cls.prefix}-pg"
cls.containers = [cls.pg_container]
cls.pg_password = "postgres"
cls.pg_user = "postgres"
run(
[
"docker",
"run",
"-d",
"--name",
cls.pg_container,
"-e",
f"POSTGRES_PASSWORD={cls.pg_password}",
"-v",
f"{cls.db_volume}:{POSTGRES_DATA_DIR}",
POSTGRES_IMAGE,
]
)
wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90)
# Create two DBs and deterministic content, so pg_dumpall is meaningful
cls.pg_db1 = "testdb1"
cls.pg_db2 = "testdb2"
run(
[
"docker",
"exec",
cls.pg_container,
"sh",
"-lc",
(
f'psql -U {cls.pg_user} -c "CREATE DATABASE {cls.pg_db1};" || true; '
f'psql -U {cls.pg_user} -c "CREATE DATABASE {cls.pg_db2};" || true; '
),
],
check=True,
)
run(
[
"docker",
"exec",
cls.pg_container,
"sh",
"-lc",
(
f"psql -U {cls.pg_user} -d {cls.pg_db1} -c "
'"CREATE TABLE IF NOT EXISTS t (id INT PRIMARY KEY, v TEXT);'
"INSERT INTO t(id,v) VALUES (1,'hello-db1') "
'ON CONFLICT (id) DO UPDATE SET v=EXCLUDED.v;"'
),
],
check=True,
)
run(
[
"docker",
"exec",
cls.pg_container,
"sh",
"-lc",
(
f"psql -U {cls.pg_user} -d {cls.pg_db2} -c "
'"CREATE TABLE IF NOT EXISTS t (id INT PRIMARY KEY, v TEXT);'
"INSERT INTO t(id,v) VALUES (1,'hello-db2') "
'ON CONFLICT (id) DO UPDATE SET v=EXCLUDED.v;"'
),
],
check=True,
)
# --- Seed databases.csv using CLI (star + concrete db) ---
cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv"
# IMPORTANT: because we pass --database-containers <container>,
# get_instance() will use the container name as instance key.
instance = cls.pg_container
# Seed star entry (pg_dumpall)
run(
[
"baudolo-seed",
cls.databases_csv,
instance,
"*",
cls.pg_user,
cls.pg_password,
]
)
# Seed concrete DB entry (pg_dump)
run(
[
"baudolo-seed",
cls.databases_csv,
instance,
cls.pg_db1,
cls.pg_user,
cls.pg_password,
]
)
# --- Run baudolo with dump-only-sql ---
cmd = [
"baudolo",
"--compose-dir",
cls.compose_dir,
"--databases-csv",
cls.databases_csv,
"--database-containers",
cls.pg_container,
"--images-no-stop-required",
POSTGRES_IMAGE,
"--dump-only-sql",
"--backups-dir",
cls.backups_dir,
"--repo-name",
cls.repo_name,
]
cp = run(cmd, capture=True, check=True)
cls.stdout = cp.stdout or ""
cls.stderr = cp.stderr or ""
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
@classmethod
def tearDownClass(cls) -> None:
cleanup_docker(containers=cls.containers, volumes=cls.volumes)
def test_db_volume_has_cluster_dump_and_concrete_db_dump_and_no_files(self) -> None:
base = backup_path(
self.backups_dir, self.repo_name, self.version, self.db_volume
)
sql_dir = base / "sql"
files_dir = base / "files"
self.assertTrue(sql_dir.exists(), f"Expected sql dir at: {sql_dir}")
self.assertFalse(
files_dir.exists(),
f"Did not expect files dir for DB volume when dump-only-sql succeeded: {files_dir}",
)
# Cluster dump file produced by '*' entry
cluster = sql_dir / f"{self.pg_container}.cluster.backup.sql"
self.assertTrue(cluster.is_file(), f"Expected cluster dump file at: {cluster}")
# Concrete DB dump produced by normal entry
db1 = sql_dir / f"{self.pg_db1}.backup.sql"
self.assertTrue(db1.is_file(), f"Expected db dump file at: {db1}")
# Basic sanity: cluster dump usually contains CREATE DATABASE statements
txt = cluster.read_text(encoding="utf-8", errors="ignore")
self.assertIn(
"CREATE DATABASE",
txt,
"Expected cluster dump to contain CREATE DATABASE statements",
)
def test_non_db_volume_still_has_files_backup(self) -> None:
base = backup_path(
self.backups_dir, self.repo_name, self.version, self.files_volume
)
files_dir = base / "files"
self.assertTrue(
files_dir.exists(), f"Expected files dir for non-DB volume at: {files_dir}"
)
marker = files_dir / "hello.txt"
self.assertTrue(marker.is_file(), f"Expected marker file at: {marker}")
self.assertEqual(
marker.read_text(encoding="utf-8").strip(),
self.marker,
)

View File

@@ -0,0 +1,141 @@
"""Snapshot capture against real filesystems.
Loop devices are only available to a privileged container, so each case builds
its filesystem inside one and drives snapshot_driver.py there. A filesystem
without snapshot support must fail loudly rather than degrade to a live copy,
which is the property that makes the mode safe to offer at all.
"""
from __future__ import annotations
import os
import shutil
import unittest
from pathlib import Path
from .helpers import require_docker, run, unique
REPO_SRC = Path(__file__).resolve().parents[2] / "src"
DRIVER = Path(__file__).resolve().parent / "snapshot_driver.py"
IMAGE = "alpine:3.20"
PACKAGES = "apk add -q btrfs-progs e2fsprogs zfs python3 util-linux"
ATTACH = (
"LOOP=$(losetup -f | awk '{print $1}') "
'&& { [ -b "$LOOP" ] || mknod "$LOOP" b 7 "${LOOP#/dev/loop}"; }; '
'losetup "$LOOP" /img'
)
LOOP_FS = {
"btrfs": "btrfs subvolume create /subject/docker >/dev/null",
"ext4": "mkdir -p /subject/docker",
}
# A container carries no /lib/modules, so modprobe fails even on a loaded module.
ZFS_READY = '{ [ -c /dev/zfs ] || modprobe zfs 2>/dev/null; }; [ -c /dev/zfs ]'
def mount_script(fstype: str) -> str:
"""Build a filesystem on a loop device and carve out the snapshot subject."""
if fstype == "zfs":
return (
f"{PACKAGES} && {ZFS_READY} && truncate -s 400M /img "
"&& zpool create -m none baudolo /img "
"&& zfs create -o mountpoint=/subject/docker baudolo/docker"
)
return (
f"{PACKAGES} && truncate -s 400M /img && mkfs.{fstype} -q /img "
f'&& mkdir -p /subject && {ATTACH} && mount -t {fstype} "$LOOP" /subject '
f"&& {LOOP_FS[fstype]}"
)
def zfs_usable() -> bool:
"""Whether this host's kernel can serve zfs to a privileged container."""
proc = run(
[
"docker", "run", "--rm", "--privileged", IMAGE, "sh", "-lc",
f"apk add -q zfs >/dev/null 2>&1 && {ZFS_READY}",
],
capture=True,
check=False,
)
return proc.returncode == 0
def required(fstype: str) -> bool:
"""Whether this run must cover ``fstype`` instead of skipping it.
CI sets E2E_REQUIRE_FILESYSTEMS so a missing kernel module fails the build
rather than passing it with a filesystem silently untested.
"""
demanded = os.environ.get("E2E_REQUIRE_FILESYSTEMS", "")
return fstype in demanded.replace(",", " ").split()
def stage() -> Path:
"""Copy source and driver under /tmp, the only path the DinD daemon shares."""
staged = Path("/tmp") / unique("baudolo-e2e-snapshot")
shutil.copytree(REPO_SRC, staged / "src")
shutil.copy(DRIVER, staged / "driver.py")
return staged
def drive(fstype: str, kind: str, expect: str) -> str:
staged = stage()
script = (
f"set -e; {mount_script(fstype)}; "
f"python3 /driver.py {kind} /subject/docker {expect}"
)
try:
proc = run(
[
"docker", "run", "--rm", "--privileged",
"--name", staged.name,
"-v", f"{staged / 'src'}:/src:ro",
"-v", f"{staged / 'driver.py'}:/driver.py:ro",
IMAGE, "sh", "-lc", script,
],
capture=True,
check=False,
)
finally:
shutil.rmtree(staged, ignore_errors=True)
if proc.returncode != 0:
raise AssertionError(f"{fstype}/{kind} driver failed:\n{proc.stdout}\n{proc.stderr}")
return proc.stdout
class TestE2ESnapshot(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
def assert_freezes(self, fstype: str) -> None:
output = drive(fstype, fstype, "supported")
self.assertIn("PASS the snapshot exposes the volume", output)
self.assertIn("PASS a later write does not reach the snapshot", output)
self.assertIn("PASS the snapshot is removed afterwards", output)
self.assertIn("ALL OK", output)
def test_btrfs_snapshot_freezes_the_volume(self) -> None:
self.assert_freezes("btrfs")
def test_zfs_snapshot_freezes_the_volume(self) -> None:
if not zfs_usable():
if required("zfs"):
self.fail(
"E2E_REQUIRE_FILESYSTEMS demands zfs, but this kernel provides no "
"zfs module; load it before running the suite"
)
self.skipTest("this kernel provides no zfs module, so no pool can be created")
self.assert_freezes("zfs")
def test_ext4_has_no_snapshot_and_says_so(self) -> None:
output = drive("ext4", "btrfs", "unsupported")
self.assertIn("PASS refused loudly", output)
def test_an_unknown_kind_is_refused_before_touching_the_filesystem(self) -> None:
output = drive("ext4", "lvm", "unsupported")
self.assertIn("PASS refused loudly", output)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,103 @@
"""A live database survives being captured from a snapshot.
The database is written to while the snapshot is taken and keeps writing
afterwards, so the copy can only be a point in time - never a clean shutdown.
A second server is then started on that copy: it must recover on its own and
still hold every row committed before the snapshot.
"""
from __future__ import annotations
import shutil
import unittest
from pathlib import Path
from .helpers import require_docker, run, unique
REPO_SRC = Path(__file__).resolve().parents[2] / "src"
DRIVER = Path(__file__).resolve().parent / "snapshot_db_driver.py"
IMAGE = "alpine:3.20"
SOCKET = "/tmp/live.sock"
RESTORED_SOCKET = "/tmp/restored.sock"
DATADIR = "/subject/docker/volumes/mariadb_data/_data"
RESTORED = "/restored"
SCRIPT = f"""set -e
apk add -q btrfs-progs util-linux python3 mariadb mariadb-client rsync
truncate -s 900M /img
mkfs.btrfs -q /img
mkdir -p /subject
LOOP=$(losetup -f | awk '{{print $1}}')
{{ [ -b "$LOOP" ] || mknod "$LOOP" b 7 "${{LOOP#/dev/loop}}"; }}
losetup "$LOOP" /img
mount -t btrfs "$LOOP" /subject
btrfs subvolume create /subject/docker >/dev/null
mkdir -p {DATADIR}
mariadb-install-db --user=root --datadir={DATADIR} >/dev/null 2>&1
mariadbd --user=root --datadir={DATADIR} --socket={SOCKET} --skip-networking &
for i in $(seq 1 60); do mariadb-admin --socket={SOCKET} ping >/dev/null 2>&1 && break; sleep 1; done
mariadb --socket={SOCKET} -e "CREATE DATABASE demo;
CREATE TABLE demo.t (id INT PRIMARY KEY, v VARCHAR(32)) ENGINE=InnoDB;
INSERT INTO demo.t VALUES (1,'committed'),(2,'committed');"
mariadb --socket={SOCKET} -e "INSERT INTO demo.t VALUES (3,'committed');"
python3 /driver.py
mariadb --socket={SOCKET} -e "INSERT INTO demo.t VALUES (4,'after-snapshot');"
mkdir -p {RESTORED}
rsync -a /backups/20260731/mariadb_data/files/ {RESTORED}/
mariadbd --user=root --datadir={RESTORED} --socket={RESTORED_SOCKET} --skip-networking &
for i in $(seq 1 60); do mariadb-admin --socket={RESTORED_SOCKET} ping >/dev/null 2>&1 && break; sleep 1; done
echo "RESTORED_ROWS=$(mariadb --socket={RESTORED_SOCKET} -N -B -e 'SELECT COUNT(*) FROM demo.t;')"
echo "RESTORED_AFTER=$(mariadb --socket={RESTORED_SOCKET} -N -B -e \\
"SELECT COUNT(*) FROM demo.t WHERE v='after-snapshot';")"
echo DB_OK
"""
class TestE2ESnapshotDatabase(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
staged = Path("/tmp") / unique("baudolo-e2e-snapshot-db")
shutil.copytree(REPO_SRC, staged / "src")
shutil.copy(DRIVER, staged / "driver.py")
try:
proc = run(
[
"docker", "run", "--rm", "--privileged",
"--name", staged.name,
"-v", f"{staged / 'src'}:/src:ro",
"-v", f"{staged / 'driver.py'}:/driver.py:ro",
IMAGE, "sh", "-lc", SCRIPT,
],
capture=True,
check=False,
)
finally:
shutil.rmtree(staged, ignore_errors=True)
cls.output = proc.stdout + proc.stderr
cls.returncode = proc.returncode
def test_the_run_completed(self) -> None:
self.assertEqual(self.returncode, 0, self.output)
self.assertIn("DB_OK", self.output)
def test_the_backup_came_from_the_snapshot(self) -> None:
self.assertIn("SNAPSHOT COPY DONE", self.output)
def test_the_restored_server_recovered_on_its_own(self) -> None:
self.assertIn("RESTORED_ROWS=3", self.output)
def test_writes_after_the_snapshot_are_absent(self) -> None:
self.assertIn("RESTORED_AFTER=0", self.output)
def test_the_copy_was_an_unclean_state_the_engine_had_to_repair(self) -> None:
self.assertRegex(self.output, r"(?i)crash recovery|rolling back|log sequence")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,196 @@
# tests/e2e/test_e2e_swarm_task_skip.py
#
# Reproduces the swarm flake fixed on this branch: baudolo used to stop a
# swarm task container around the volume file backup because its image was
# not whitelisted; the orchestrator immediately replaced the stopped task and
# the later `docker start` failed on the detached overlay network, killing
# the backup run. With the fix the task container is skipped (backed up hot):
# the backup succeeds, the very same container instance keeps running, and
# the service never has to replace a task.
import time
import unittest
from .helpers import (
backup_path,
backup_run,
create_minimal_compose_dir,
ensure_empty_dir,
latest_version_dir,
require_docker,
run,
unique,
write_databases_csv,
)
def _swarm_state() -> str:
return run(
["docker", "info", "--format", "{{.Swarm.LocalNodeState}}"]
).stdout.strip()
def _task_container_id(service: str, timeout_s: int = 60) -> str:
deadline = time.time() + timeout_s
while time.time() < deadline:
out = run(
[
"docker",
"ps",
"--filter",
f"label=com.docker.swarm.service.name={service}",
"--format",
"{{.ID}}",
]
).stdout.strip()
if out:
return out.splitlines()[0]
time.sleep(2)
raise RuntimeError(f"No running task container for service {service}")
def _started_at(container_id: str) -> str:
return run(
["docker", "inspect", "--format", "{{.State.StartedAt}}", container_id]
).stdout.strip()
class TestE2ESwarmTaskSkip(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
cls.prefix = unique("baudolo-e2e-swarm-skip")
cls.backups_dir = f"/tmp/{cls.prefix}/Backups"
ensure_empty_dir(cls.backups_dir)
cls.compose_dir = create_minimal_compose_dir(f"/tmp/{cls.prefix}")
cls.repo_name = cls.prefix
cls.swarm_initted = False
if _swarm_state() != "active":
run(["docker", "swarm", "init", "--advertise-addr", "127.0.0.1"])
cls.swarm_initted = True
cls.volume = f"{cls.prefix}-vol"
cls.service = f"{cls.prefix}-svc"
cls.volumes = [cls.volume]
run(["docker", "volume", "create", cls.volume])
run(
[
"docker",
"run",
"--rm",
"-v",
f"{cls.volume}:/data",
"alpine:3.20",
"sh",
"-lc",
"echo 'swarm-payload' > /data/payload.txt",
]
)
run(
[
"docker",
"service",
"create",
"--name",
cls.service,
"--replicas",
"1",
"--mount",
f"type=volume,source={cls.volume},target=/data",
"alpine:3.20",
"sleep",
"3600",
]
)
cls.task_cid = _task_container_id(cls.service)
cls.task_started_at = _started_at(cls.task_cid)
cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv"
write_databases_csv(cls.databases_csv, [])
# Whitelist that matches nothing: on main this forces a stop of every
# container at the volume, i.e. exactly the flake; on this branch the
# swarm task must be skipped instead. (An empty list would leave the
# --images-no-stop-required flag without arguments and argparse-fail.)
backup_run(
backups_dir=cls.backups_dir,
repo_name=cls.repo_name,
compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv,
database_containers=["dummy-db"],
images_no_stop_required=["image-that-matches-nothing"],
)
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
@classmethod
def tearDownClass(cls) -> None:
run(["docker", "service", "rm", cls.service], check=False)
deadline = time.time() + 30
while time.time() < deadline:
out = run(
[
"docker",
"ps",
"-aq",
"--filter",
f"label=com.docker.swarm.service.name={cls.service}",
],
check=False,
).stdout.strip()
if not out:
break
time.sleep(2)
for v in cls.volumes:
run(["docker", "volume", "rm", "-f", v], check=False)
if cls.swarm_initted:
run(["docker", "swarm", "leave", "--force"], check=False)
def test_volume_backed_up_hot(self) -> None:
p = (
backup_path(
self.backups_dir,
self.repo_name,
self.version,
self.volume,
)
/ "files"
/ "payload.txt"
)
self.assertTrue(p.is_file(), f"Expected backed up file at: {p}")
def test_task_container_never_stopped(self) -> None:
out = run(
["docker", "ps", "-q", "--no-trunc", "--filter", f"id={self.task_cid}"]
).stdout.strip()
self.assertTrue(
out.startswith(self.task_cid) or self.task_cid.startswith(out.strip()[:12]),
f"Task container {self.task_cid} is no longer running",
)
self.assertEqual(
self.task_started_at,
_started_at(self.task_cid),
"Task container was restarted during the backup",
)
def test_service_never_replaced_the_task(self) -> None:
states = run(
[
"docker",
"service",
"ps",
self.service,
"--format",
"{{.DesiredState}} {{.CurrentState}}",
]
).stdout.strip()
lines = [line for line in states.splitlines() if line.strip()]
self.assertEqual(
len(lines), 1, f"Service task history shows replacements:\n{states}"
)
self.assertIn("Running", lines[0])
if __name__ == "__main__":
unittest.main()

View File

View File

@@ -0,0 +1,213 @@
import csv
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
def run_seed(
csv_path: Path, instance: str, database: str, username: str, password: str
) -> subprocess.CompletedProcess:
"""
Run the real CLI module (E2E-style) using subprocess.
Seed contract (current):
- database must be "*" or a valid name (non-empty, matches allowed charset)
- password is required
- entry is keyed by (instance, database); username/password get updated
"""
cp = subprocess.run(
[
sys.executable,
"-m",
"baudolo.seed",
str(csv_path),
instance,
database,
username,
password,
],
text=True,
capture_output=True,
check=False,
)
if cp.returncode != 0:
raise AssertionError(
"seed command failed unexpectedly.\n"
f"returncode: {cp.returncode}\n"
f"stdout:\n{cp.stdout}\n"
f"stderr:\n{cp.stderr}\n"
)
return cp
def run_seed_expect_fail(
csv_path: Path, instance: str, database: str, username: str, password: str
) -> subprocess.CompletedProcess:
"""
Same as run_seed, but expects non-zero exit. Returns CompletedProcess for inspection.
"""
return subprocess.run(
[
sys.executable,
"-m",
"baudolo.seed",
str(csv_path),
instance,
database,
username,
password,
],
text=True,
capture_output=True,
check=False,
)
def read_csv_semicolon(path: Path) -> list[dict]:
with path.open("r", encoding="utf-8", newline="") as f:
reader = csv.DictReader(f, delimiter=";")
return list(reader)
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
class TestSeedIntegration(unittest.TestCase):
def test_creates_file_and_adds_entry_when_missing(self) -> None:
with tempfile.TemporaryDirectory() as td:
p = Path(td) / "databases.csv"
self.assertFalse(p.exists())
cp = run_seed(p, "docker.test", "appdb", "alice", "secret")
self.assertEqual(cp.returncode, 0)
self.assertTrue(p.exists())
rows = read_csv_semicolon(p)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["instance"], "docker.test")
self.assertEqual(rows[0]["database"], "appdb")
self.assertEqual(rows[0]["username"], "alice")
self.assertEqual(rows[0]["password"], "secret")
def test_replaces_existing_entry_same_instance_and_database_updates_username_and_password(
self,
) -> None:
"""
Replacement semantics:
- Key is (instance, database)
- username/password are updated in-place
"""
with tempfile.TemporaryDirectory() as td:
p = Path(td) / "databases.csv"
run_seed(p, "docker.test", "appdb", "alice", "oldpw")
rows = read_csv_semicolon(p)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["username"], "alice")
self.assertEqual(rows[0]["password"], "oldpw")
run_seed(p, "docker.test", "appdb", "bob", "newpw")
rows = read_csv_semicolon(p)
self.assertEqual(len(rows), 1, "Expected replacement, not a duplicate row")
self.assertEqual(rows[0]["instance"], "docker.test")
self.assertEqual(rows[0]["database"], "appdb")
self.assertEqual(rows[0]["username"], "bob")
self.assertEqual(rows[0]["password"], "newpw")
def test_allows_star_database_for_dump_all(self) -> None:
with tempfile.TemporaryDirectory() as td:
p = Path(td) / "databases.csv"
cp = run_seed(p, "bigbluebutton", "*", "postgres", "pw")
self.assertEqual(cp.returncode, 0)
rows = read_csv_semicolon(p)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["instance"], "bigbluebutton")
self.assertEqual(rows[0]["database"], "*")
self.assertEqual(rows[0]["username"], "postgres")
self.assertEqual(rows[0]["password"], "pw")
def test_replaces_existing_star_entry(self) -> None:
with tempfile.TemporaryDirectory() as td:
p = Path(td) / "databases.csv"
run_seed(p, "bigbluebutton", "*", "postgres", "pw1")
run_seed(p, "bigbluebutton", "*", "postgres", "pw2")
rows = read_csv_semicolon(p)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["database"], "*")
self.assertEqual(rows[0]["password"], "pw2")
def test_rejects_empty_database_value(self) -> None:
with tempfile.TemporaryDirectory() as td:
p = Path(td) / "databases.csv"
cp = run_seed_expect_fail(p, "docker.test", "", "alice", "pw")
self.assertNotEqual(cp.returncode, 0)
combined = ((cp.stdout or "") + "\n" + (cp.stderr or "")).lower()
self.assertIn("error:", combined)
self.assertIn("database", combined)
self.assertIn("not empty", combined)
self.assertFalse(p.exists(), "Should not create file on invalid input")
def test_rejects_invalid_database_name_characters(self) -> None:
with tempfile.TemporaryDirectory() as td:
p = Path(td) / "databases.csv"
cp = run_seed_expect_fail(p, "docker.test", "app db", "alice", "pw")
self.assertNotEqual(cp.returncode, 0)
combined = ((cp.stdout or "") + "\n" + (cp.stderr or "")).lower()
self.assertIn("error:", combined)
self.assertIn("invalid database name", combined)
self.assertFalse(p.exists(), "Should not create file on invalid input")
def test_rejects_nan_database_name(self) -> None:
with tempfile.TemporaryDirectory() as td:
p = Path(td) / "databases.csv"
cp = run_seed_expect_fail(p, "docker.test", "nan", "alice", "pw")
self.assertNotEqual(cp.returncode, 0)
combined = ((cp.stdout or "") + "\n" + (cp.stderr or "")).lower()
self.assertIn("error:", combined)
self.assertIn("must not be 'nan'", combined)
self.assertFalse(p.exists(), "Should not create file on invalid input")
def test_accepts_hyphen_and_underscore_database_names(self) -> None:
with tempfile.TemporaryDirectory() as td:
p = Path(td) / "databases.csv"
run_seed(p, "docker.test", "my_db-1", "alice", "pw")
rows = read_csv_semicolon(p)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["database"], "my_db-1")
def test_file_is_semicolon_delimited_and_has_header(self) -> None:
with tempfile.TemporaryDirectory() as td:
p = Path(td) / "databases.csv"
run_seed(p, "docker.test", "appdb", "alice", "pw")
txt = read_text(p)
self.assertTrue(
txt.startswith("instance;database;username;password"),
f"Unexpected header / delimiter in file:\n{txt}",
)
self.assertIn(";", txt)
if __name__ == "__main__":
unittest.main()

0
tests/unit/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,44 @@
"""Builds the on-disk compose directories the compose tests discover."""
from __future__ import annotations
import shutil
from pathlib import Path
def touch(p: Path) -> None:
p.parent.mkdir(parents=True, exist_ok=True)
# ".env/env" leaves ".env" behind as a directory, which blocks a later ".env" file.
if p.exists() and p.is_dir():
shutil.rmtree(p)
p.write_text("x", encoding="utf-8")
def setup_compose_dir(
tmp_path: Path,
name: str = "mailu",
*,
compose_name: str = "docker-compose.yml",
with_override: bool = False,
with_ca_override: bool = False,
env_layout: str | None = None, # None | ".env" | ".env/env"
) -> Path:
d = tmp_path / name
d.mkdir(parents=True, exist_ok=True)
touch(d / compose_name)
if with_override:
touch(d / "docker-compose.override.yml")
if with_ca_override:
touch(d / "docker-compose.ca.override.yml")
if env_layout == ".env":
touch(d / ".env")
elif env_layout == ".env/env":
touch(d / ".env" / "env")
return d

View File

@@ -0,0 +1,77 @@
import io
import os
import tempfile
import unittest
from contextlib import redirect_stderr
import pandas as pd
# Adjust if your package name/import path differs.
from baudolo.backup.dumps import load_databases_df
EXPECTED_COLUMNS = ["instance", "database", "username", "password"]
class TestLoadDatabasesDf(unittest.TestCase):
def test_missing_csv_is_handled_with_warning_and_empty_df(self) -> None:
with tempfile.TemporaryDirectory() as td:
missing_path = os.path.join(td, "does-not-exist.csv")
buf = io.StringIO()
with redirect_stderr(buf):
df = load_databases_df(missing_path)
stderr = buf.getvalue()
self.assertIn("WARNING:", stderr)
self.assertIn("databases.csv not found", stderr)
self.assertIsInstance(df, pd.DataFrame)
self.assertListEqual(list(df.columns), EXPECTED_COLUMNS)
self.assertTrue(df.empty)
def test_empty_csv_is_handled_with_warning_and_empty_df(self) -> None:
with tempfile.TemporaryDirectory() as td:
empty_path = os.path.join(td, "databases.csv")
# Create an empty file (0 bytes)
with open(empty_path, "w", encoding="utf-8") as f:
f.write("")
buf = io.StringIO()
with redirect_stderr(buf):
df = load_databases_df(empty_path)
stderr = buf.getvalue()
self.assertIn("WARNING:", stderr)
self.assertIn("exists but is empty", stderr)
self.assertIsInstance(df, pd.DataFrame)
self.assertListEqual(list(df.columns), EXPECTED_COLUMNS)
self.assertTrue(df.empty)
def test_valid_csv_loads_without_warning(self) -> None:
with tempfile.TemporaryDirectory() as td:
csv_path = os.path.join(td, "databases.csv")
content = "instance;database;username;password\nmyapp;*;dbuser;secret\n"
with open(csv_path, "w", encoding="utf-8") as f:
f.write(content)
buf = io.StringIO()
with redirect_stderr(buf):
df = load_databases_df(csv_path)
stderr = buf.getvalue()
self.assertEqual(stderr, "") # no warning expected
self.assertIsInstance(df, pd.DataFrame)
self.assertListEqual(list(df.columns), EXPECTED_COLUMNS)
self.assertEqual(len(df), 1)
self.assertEqual(df.loc[0, "instance"], "myapp")
self.assertEqual(df.loc[0, "database"], "*")
self.assertEqual(df.loc[0, "username"], "dbuser")
self.assertEqual(df.loc[0, "password"], "secret")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,79 @@
"""Contract of app.main's snapshot branch - the caller that runs in production."""
from __future__ import annotations
import unittest
from unittest import mock
from baudolo.backup import app
from baudolo.backup.snapshot import volume_snapshot
def stubbed_snapshot(kind: str, subject: str, tag: str):
return volume_snapshot(kind, subject, tag, run=lambda command: [])
ARGV = [
"baudolo",
"--compose-dir",
"/compose",
"--backups-dir",
"/backups",
"--snapshot",
"btrfs",
"--snapshot-subject",
"/var/lib/docker",
]
def drive(*, present: bool) -> list[dict]:
calls: list[dict] = []
def record(versions_dir, volume_name, volume_dir, *, authoritative, source):
calls.append(
{"volume": volume_name, "authoritative": authoritative, "source": source}
)
with (
mock.patch("sys.argv", ARGV),
mock.patch.object(app, "get_machine_id", return_value="machine"),
mock.patch.object(app, "create_version_directory", return_value="/gen"),
mock.patch.object(app, "create_volume_directory", return_value="/gen/vol"),
mock.patch.object(app, "load_databases_df", return_value=None),
mock.patch.object(app, "docker_volume_names", return_value=["vol"]),
mock.patch.object(app, "containers_using_volume", return_value=[]),
mock.patch.object(app, "volume_is_fully_ignored", return_value=False),
mock.patch.object(app, "backup_dumps_for_volume", return_value=(False, False)),
mock.patch.object(
app, "get_storage_path", return_value="/var/lib/docker/volumes/vol/_data/"
),
mock.patch.object(app, "stamp_directory"),
mock.patch.object(app, "handle_docker_compose_services"),
mock.patch.object(app.os.path, "isdir", return_value=present),
mock.patch.object(app, "backup_volume", side_effect=record),
mock.patch.object(app, "volume_snapshot", stubbed_snapshot),
):
app.main()
return calls
class TestSnapshotBranch(unittest.TestCase):
def test_it_passes_a_path_ending_in_a_separator(self) -> None:
source = drive(present=True)[0]["source"]
self.assertTrue(source.endswith("/volumes/vol/_data/"), source)
self.assertNotIn("/var/lib/docker/volumes", source)
def test_it_reads_from_the_snapshot_and_not_from_the_live_tree(self) -> None:
source = drive(present=True)[0]["source"]
self.assertTrue(source.startswith("/var/lib/docker/.baudolo-"), source)
def test_it_compares_by_content_against_the_previous_generation(self) -> None:
self.assertTrue(drive(present=True)[0]["authoritative"])
def test_a_volume_missing_from_the_snapshot_is_copied_live(self) -> None:
call = drive(present=False)[0]
self.assertEqual(call["source"], "/var/lib/docker/volumes/vol/_data/")
self.assertFalse(call["authoritative"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,81 @@
"""Contract of the backup CLI, in particular the snapshot flag pairing."""
from __future__ import annotations
import unittest
from unittest import mock
from baudolo.backup.cli import parse_args
REQUIRED = ["--compose-dir", "/compose", "--backups-dir", "/backups"]
def parse(*extra: str):
with mock.patch("sys.argv", ["baudolo", *REQUIRED, *extra]):
return parse_args()
class TestSnapshotFlags(unittest.TestCase):
def test_no_snapshot_by_default(self) -> None:
args = parse()
self.assertIsNone(args.snapshot)
self.assertIsNone(args.snapshot_subject)
def test_both_flags_together_are_accepted(self) -> None:
args = parse("--snapshot", "btrfs", "--snapshot-subject", "/var/lib/docker")
self.assertEqual(args.snapshot, "btrfs")
self.assertEqual(args.snapshot_subject, "/var/lib/docker")
def test_the_kind_alone_is_rejected(self) -> None:
with self.assertRaises(SystemExit):
parse("--snapshot", "btrfs")
def test_the_subject_alone_is_rejected(self) -> None:
with self.assertRaises(SystemExit):
parse("--snapshot-subject", "/var/lib/docker")
def test_an_unsupported_kind_is_rejected(self) -> None:
with self.assertRaises(SystemExit):
parse("--snapshot", "ext4", "--snapshot-subject", "/var/lib/docker")
def test_zfs_is_accepted(self) -> None:
self.assertEqual(parse("--snapshot", "zfs", "--snapshot-subject", "/d").snapshot, "zfs")
def test_shutdown_is_rejected_because_nothing_is_stopped(self) -> None:
with self.assertRaises(SystemExit):
parse("--snapshot", "btrfs", "--snapshot-subject", "/d", "--shutdown")
def test_shutdown_stays_available_without_a_snapshot(self) -> None:
self.assertTrue(parse("--shutdown").shutdown)
def test_hard_restart_is_rejected_because_nothing_is_stopped(self) -> None:
with self.assertRaises(SystemExit):
parse(
"--snapshot",
"btrfs",
"--snapshot-subject",
"/d",
"--hard-restart-projects",
"mailu",
)
def test_hard_restart_stays_available_without_a_snapshot(self) -> None:
self.assertEqual(
parse("--hard-restart-projects", "mailu").hard_restart_projects, ["mailu"]
)
class TestRequiredFlags(unittest.TestCase):
def test_backups_dir_is_required(self) -> None:
with mock.patch("sys.argv", ["baudolo", "--compose-dir", "/compose"]):
with self.assertRaises(SystemExit):
parse_args()
def test_compose_dir_is_required(self) -> None:
with mock.patch("sys.argv", ["baudolo", "--backups-dir", "/backups"]):
with self.assertRaises(SystemExit):
parse_args()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,213 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from typing import List
from unittest.mock import patch
from .compose_fixture import setup_compose_dir as _setup_compose_dir
class TestCompose(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
from baudolo.backup import compose as mod
cls.compose_mod = mod
def test_find_compose_file_supports_all_valid_names_case_insensitive(self) -> None:
with tempfile.TemporaryDirectory() as td:
tmp_path = Path(td)
variants = [
"compose.yml",
"compose.yaml",
"docker-compose.yml",
"docker-compose.yaml",
"docker-compose.yAml",
]
for i, name in enumerate(variants):
d = _setup_compose_dir(
tmp_path,
name=f"project{i}",
compose_name=name,
)
found = self.compose_mod._find_compose_file(str(d))
self.assertIsNotNone(found)
self.assertEqual(found.name, name)
def test_find_compose_file_returns_none_when_missing(self) -> None:
with tempfile.TemporaryDirectory() as td:
tmp_path = Path(td)
d = tmp_path / "empty"
d.mkdir(parents=True, exist_ok=True)
found = self.compose_mod._find_compose_file(str(d))
self.assertIsNone(found)
def test_build_cmd_uses_wrapper_when_present(self) -> None:
with tempfile.TemporaryDirectory() as td:
tmp_path = Path(td)
d = _setup_compose_dir(
tmp_path,
with_override=True,
with_ca_override=True,
env_layout=".env",
)
def fake_which(name: str):
if name == "compose":
return "/usr/local/bin/compose"
return None
with patch.object(self.compose_mod.shutil, "which", fake_which):
cmd = self.compose_mod._build_compose_cmd(str(d), ["up", "-d"])
self.assertEqual(
cmd,
[
"/usr/local/bin/compose",
"--chdir",
str(d.resolve()),
"--",
"up",
"-d",
],
)
def test_build_cmd_fallback_uses_plain_docker_compose_chdir(self) -> None:
with tempfile.TemporaryDirectory() as td:
tmp_path = Path(td)
d = _setup_compose_dir(
tmp_path,
with_override=True,
with_ca_override=True,
env_layout=".env",
)
def fake_which(name: str):
if name == "compose":
return None
if name == "docker":
return "/usr/bin/docker"
return None
with patch.object(self.compose_mod.shutil, "which", fake_which):
cmd = self.compose_mod._build_compose_cmd(
str(d), ["up", "-d", "--force-recreate"]
)
expected: List[str] = [
"/usr/bin/docker",
"compose",
"--chdir",
str(d.resolve()),
"up",
"-d",
"--force-recreate",
]
self.assertEqual(cmd, expected)
def test_hard_restart_calls_run_twice_with_correct_cmds_wrapper(self) -> None:
with tempfile.TemporaryDirectory() as td:
tmp_path = Path(td)
d = _setup_compose_dir(tmp_path, name="mailu", env_layout=".env")
def fake_which(name: str):
if name == "compose":
return "/usr/local/bin/compose"
return None
with patch.object(self.compose_mod.shutil, "which", fake_which):
calls = []
def fake_run(cmd, check: bool):
calls.append((cmd, check))
return 0
with patch.object(self.compose_mod.subprocess, "run", fake_run):
self.compose_mod.hard_restart_docker_services(str(d))
self.assertEqual(
calls,
[
(
[
"/usr/local/bin/compose",
"--chdir",
str(d.resolve()),
"--",
"down",
],
True,
),
(
[
"/usr/local/bin/compose",
"--chdir",
str(d.resolve()),
"--",
"up",
"-d",
],
True,
),
],
)
def test_hard_restart_calls_run_twice_with_correct_cmds_fallback(self) -> None:
with tempfile.TemporaryDirectory() as td:
tmp_path = Path(td)
d = _setup_compose_dir(
tmp_path,
name="mailu",
with_override=True,
with_ca_override=True,
env_layout=".env/env",
)
def fake_which(name: str):
if name == "compose":
return None
if name == "docker":
return "/usr/bin/docker"
return None
with patch.object(self.compose_mod.shutil, "which", fake_which):
calls = []
def fake_run(cmd, check: bool):
calls.append((cmd, check))
return 0
with patch.object(self.compose_mod.subprocess, "run", fake_run):
self.compose_mod.hard_restart_docker_services(str(d))
self.assertEqual(
calls,
[
(
[
"/usr/bin/docker",
"compose",
"--chdir",
str(d.resolve()),
"down",
],
True,
),
(
[
"/usr/bin/docker",
"compose",
"--chdir",
str(d.resolve()),
"up",
"-d",
],
True,
),
],
)

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import unittest
from typing import List
from unittest.mock import patch
class HardRestartArgTests(unittest.TestCase):
"""The hard-restart list defaults to empty (no compose down/up); callers
opt in per dir, e.g. compose hosts pass 'mailu' while swarm hosts, where
the dir is a stack whose overlay network collides with compose up, pass
nothing."""
def _parse(self, extra: List[str]):
import sys
from baudolo.backup import cli
argv = [
"baudolo",
"--compose-dir",
"/tmp",
"--backups-dir",
"/tmp/backup",
"--database-containers",
"postgres",
"--images-no-stop-required",
"redis",
*extra,
]
with patch.object(sys, "argv", argv):
return cli.parse_args()
def test_default_is_empty(self) -> None:
args = self._parse([])
self.assertEqual(args.hard_restart_projects, [])
def test_empty_flag_stays_empty(self) -> None:
args = self._parse(["--hard-restart-projects"])
self.assertEqual(args.hard_restart_projects, [])
def test_explicit_names_preserved(self) -> None:
args = self._parse(["--hard-restart-projects", "mailu", "foo"])
self.assertEqual(args.hard_restart_projects, ["mailu", "foo"])
def test_backups_dir_is_required(self) -> None:
import sys
from baudolo.backup import cli
argv = [
"baudolo",
"--compose-dir",
"/tmp",
"--database-containers",
"postgres",
"--images-no-stop-required",
"redis",
]
with patch.object(sys, "argv", argv), self.assertRaises(SystemExit):
cli.parse_args()
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -0,0 +1,70 @@
import tempfile
import unittest
from unittest.mock import patch
import pandas
from baudolo.backup import db as db_mod
def _df(rows):
return pandas.DataFrame(
rows, columns=["instance", "database", "username", "password"]
)
def _capture_commands(*, db_type, rows, container):
captured = []
def _capture(cmd):
captured.append(cmd)
return []
with tempfile.TemporaryDirectory() as td:
with patch.object(db_mod, "execute_shell_command", side_effect=_capture):
db_mod.backup_database(
container=container,
volume_dir=td,
db_type=db_type,
databases_df=_df(rows),
database_containers=[container],
)
return captured
class TestMariaDBDumpUsesTCP(unittest.TestCase):
# Regression guard for 'Access denied for user <user>@localhost' when only
# '<user>'@'%' is granted: the in-container mariadb-dump MUST force TCP so
# the connection is auth-matched against '%' instead of socket->localhost.
def test_mariadb_dump_forces_tcp_loopback(self):
captured = _capture_commands(
db_type="mariadb",
rows=[("mariadb", "appdb", "appuser", "s3cret")],
container="mariadb",
)
dump_cmds = [c for c in captured if "mariadb-dump" in c]
self.assertEqual(
len(dump_cmds), 1, f"expected one dump command, got: {captured}"
)
cmd = dump_cmds[0]
self.assertIn("-h 127.0.0.1", cmd)
self.assertIn("--protocol=tcp", cmd)
self.assertIn("-u appuser", cmd)
self.assertIn("-ps3cret", cmd)
self.assertIn(" appdb", cmd)
def test_postgres_dump_unaffected(self):
captured = _capture_commands(
db_type="postgres",
rows=[("pg", "appdb", "appuser", "s3cret")],
container="pg",
)
dump_cmds = [c for c in captured if "pg_dump" in c and "pg_dumpall" not in c]
self.assertEqual(len(dump_cmds), 1)
self.assertNotIn("--protocol=tcp", dump_cmds[0])
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -0,0 +1,53 @@
import unittest
from unittest.mock import patch
from baudolo.backup import docker as docker_mod
from baudolo.backup.shell import BackupException
class TestIsSwarmTask(unittest.TestCase):
@patch.object(docker_mod, "execute_shell_command", return_value=["task-id-123"])
def test_true_when_task_label_present(self, _mock) -> None:
self.assertTrue(docker_mod.is_swarm_task("c1"))
@patch.object(docker_mod, "execute_shell_command", return_value=[""])
def test_false_when_label_empty(self, _mock) -> None:
self.assertFalse(docker_mod.is_swarm_task("c1"))
@patch.object(docker_mod, "execute_shell_command", return_value=[])
def test_false_when_no_output(self, _mock) -> None:
self.assertFalse(docker_mod.is_swarm_task("c1"))
@patch.object(
docker_mod,
"execute_shell_command",
side_effect=[BackupException("gone"), []],
)
def test_vanished_container_counts_as_not_stoppable(self, _mock) -> None:
# A container removed between listing and inspect must not abort the
# whole backup run; treating it as a swarm task keeps it out of every
# stop/start and image-inspect path.
self.assertTrue(docker_mod.is_swarm_task("gone-container"))
@patch.object(
docker_mod,
"execute_shell_command",
side_effect=[BackupException("daemon hiccup"), ["still-here"]],
)
def test_inspect_failure_on_existing_container_still_fails(self, _mock) -> None:
# If the container still exists, an inspect failure must keep failing
# the run: silently skipping the stop would back up a hot volume and
# report green without the stop guarantee.
with self.assertRaises(BackupException):
docker_mod.is_swarm_task("still-here")
class TestFilterStoppable(unittest.TestCase):
@patch.object(docker_mod, "is_swarm_task", side_effect=[False, True, False])
def test_mixed_list_keeps_order_and_drops_tasks(self, _mock) -> None:
result = docker_mod.filter_stoppable(["plain-1", "swarm-task", "plain-2"])
self.assertEqual(result, ["plain-1", "plain-2"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,51 @@
"""Contract of where a backup run puts its directories."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from baudolo.backup import layout as mod
class TestVersionDirectory(unittest.TestCase):
def test_it_creates_the_generation_directory(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
created = mod.create_version_directory(tmp, "20260731020304")
self.assertTrue(Path(created).is_dir())
self.assertEqual(Path(created).name, "20260731020304")
def test_it_is_idempotent(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
first = mod.create_version_directory(tmp, "20260731")
second = mod.create_version_directory(tmp, "20260731")
self.assertEqual(first, second)
def test_it_creates_missing_parents(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
nested = str(Path(tmp) / "machine" / "repo")
created = mod.create_version_directory(nested, "20260731")
self.assertTrue(Path(created).is_dir())
class TestVolumeDirectory(unittest.TestCase):
def test_it_nests_the_volume_under_the_generation(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
created = mod.create_volume_directory(tmp, "postgres_data")
self.assertEqual(Path(created).parent, Path(tmp))
self.assertTrue(Path(created).is_dir())
class TestMachineId(unittest.TestCase):
def test_it_takes_the_hash_without_the_filename(self) -> None:
digest = "a" * 64
with mock.patch.object(
mod, "execute_shell_command", return_value=[f"{digest} /etc/machine-id"]
):
self.assertEqual(mod.get_machine_id(), digest)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,68 @@
"""Contract of the rules deciding what is backed up and what must stop."""
from __future__ import annotations
import unittest
from unittest import mock
from baudolo.backup import policy as mod
class TestIsImageIgnored(unittest.TestCase):
def test_an_empty_whitelist_ignores_nothing(self) -> None:
self.assertFalse(mod.is_image_ignored("c1", []))
def test_a_listed_image_is_ignored(self) -> None:
with mock.patch.object(mod, "get_image_info", return_value="alpine:3.20"):
self.assertTrue(mod.is_image_ignored("c1", ["alpine:3.20"]))
def test_matching_is_exact(self) -> None:
with mock.patch.object(mod, "get_image_info", return_value="alpine:3.21"):
self.assertFalse(mod.is_image_ignored("c1", ["alpine:3.20"]))
class TestVolumeIsFullyIgnored(unittest.TestCase):
def test_a_volume_without_containers_is_kept(self) -> None:
self.assertFalse(mod.volume_is_fully_ignored([], ["alpine:3.20"]))
def test_it_needs_every_container_to_be_ignored(self) -> None:
with mock.patch.object(mod, "get_image_info", side_effect=["a", "b"]):
self.assertFalse(mod.volume_is_fully_ignored(["c1", "c2"], ["a"]))
def test_all_ignored_skips_the_volume(self) -> None:
with mock.patch.object(mod, "get_image_info", side_effect=["a", "a"]):
self.assertTrue(mod.volume_is_fully_ignored(["c1", "c2"], ["a"]))
class TestRequiresStop(unittest.TestCase):
def test_no_containers_means_no_stop(self) -> None:
self.assertFalse(mod.requires_stop([], []))
def test_a_swarm_task_never_forces_a_stop(self) -> None:
with mock.patch.object(mod, "is_swarm_task", return_value=True):
self.assertFalse(mod.requires_stop(["c1"], []))
def test_a_whitelisted_image_does_not_force_a_stop(self) -> None:
with (
mock.patch.object(mod, "is_swarm_task", return_value=False),
mock.patch.object(mod, "get_image_info", return_value="alpine:3.20"),
):
self.assertFalse(mod.requires_stop(["c1"], ["alpine:3.20"]))
def test_an_unlisted_image_forces_a_stop(self) -> None:
with (
mock.patch.object(mod, "is_swarm_task", return_value=False),
mock.patch.object(mod, "get_image_info", return_value="postgres:17"),
):
self.assertTrue(mod.requires_stop(["c1"], ["alpine:3.20"]))
def test_one_unlisted_container_is_enough(self) -> None:
with (
mock.patch.object(mod, "is_swarm_task", return_value=False),
mock.patch.object(mod, "get_image_info", side_effect=["alpine:3.20", "x"]),
):
self.assertTrue(mod.requires_stop(["c1", "c2"], ["alpine:3.20"]))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,134 @@
"""Contract of the filesystem snapshot used to capture volumes atomically."""
from __future__ import annotations
import unittest
from baudolo.backup.shell import BackupException
from baudolo.backup.snapshot import SnapshotError, volume_snapshot
class Runner:
def __init__(self, replies: dict[str, list[str]] | None = None) -> None:
self.calls: list[str] = []
self.replies = replies or {}
def __call__(self, command: str) -> list[str]:
self.calls.append(command)
for prefix, reply in self.replies.items():
if command.startswith(prefix):
return reply
return []
class TestBtrfs(unittest.TestCase):
def test_it_creates_a_read_only_snapshot_inside_the_subject(self) -> None:
run = Runner()
with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run):
pass
self.assertEqual(
run.calls[0],
"btrfs subvolume snapshot -r /var/lib/docker /var/lib/docker/.baudolo-20260731",
)
def test_it_removes_the_snapshot_afterwards(self) -> None:
run = Runner()
with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run):
pass
self.assertEqual(run.calls[-1], "btrfs subvolume delete /var/lib/docker/.baudolo-20260731")
def test_it_maps_a_volume_path_into_the_snapshot(self) -> None:
run = Runner()
with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run) as resolve:
self.assertEqual(
resolve("/var/lib/docker/volumes/postgres_data/_data"),
"/var/lib/docker/.baudolo-20260731/volumes/postgres_data/_data",
)
def test_it_keeps_the_trailing_slash_rsync_reads_as_contents(self) -> None:
run = Runner()
with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run) as resolve:
self.assertEqual(
resolve("/var/lib/docker/volumes/postgres_data/_data/"),
"/var/lib/docker/.baudolo-20260731/volumes/postgres_data/_data/",
)
def test_it_removes_the_snapshot_even_when_the_body_raises(self) -> None:
run = Runner()
with self.assertRaises(ZeroDivisionError):
with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run):
raise ZeroDivisionError
self.assertTrue(run.calls[-1].startswith("btrfs subvolume delete"))
class TestZfs(unittest.TestCase):
def _run(self) -> Runner:
return Runner({"zfs list": ["tank/docker"]})
def test_it_snapshots_the_dataset_mounted_at_the_subject(self) -> None:
run = self._run()
with volume_snapshot("zfs", "/var/lib/docker", "20260731", run=run):
pass
self.assertIn("zfs snapshot tank/docker@baudolo-20260731", run.calls)
def test_it_destroys_the_snapshot_afterwards(self) -> None:
run = self._run()
with volume_snapshot("zfs", "/var/lib/docker", "20260731", run=run):
pass
self.assertEqual(run.calls[-1], "zfs destroy tank/docker@baudolo-20260731")
def test_it_maps_a_volume_path_through_the_dot_zfs_directory(self) -> None:
run = self._run()
with volume_snapshot("zfs", "/var/lib/docker", "20260731", run=run) as resolve:
self.assertEqual(
resolve("/var/lib/docker/volumes/postgres_data/_data"),
"/var/lib/docker/.zfs/snapshot/baudolo-20260731/volumes/postgres_data/_data",
)
def test_an_unmounted_dataset_is_an_error(self) -> None:
run = Runner({"zfs list": [""]})
with self.assertRaises(SnapshotError):
with volume_snapshot("zfs", "/var/lib/docker", "20260731", run=run):
pass
class TestRejections(unittest.TestCase):
def test_an_unknown_kind_is_rejected(self) -> None:
run = Runner()
with self.assertRaises(SnapshotError):
with volume_snapshot("ext4", "/var/lib/docker", "20260731", run=run):
pass
self.assertEqual(run.calls, [])
def test_a_path_outside_the_subject_is_rejected(self) -> None:
run = Runner()
with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run) as resolve:
with self.assertRaises(SnapshotError):
resolve("/etc/passwd")
def test_the_subject_itself_resolves_to_the_snapshot_root(self) -> None:
run = Runner()
with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=run) as resolve:
self.assertEqual(resolve("/var/lib/docker"), "/var/lib/docker/.baudolo-20260731")
class Busy(Runner):
def __call__(self, command: str) -> list[str]:
if command.startswith("btrfs subvolume delete"):
raise BackupException("target is busy")
return super().__call__(command)
class TestRemovalFailure(unittest.TestCase):
def test_a_failed_removal_does_not_fail_a_completed_run(self) -> None:
with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=Busy()):
pass
def test_a_failed_removal_does_not_mask_the_body(self) -> None:
with self.assertRaises(ZeroDivisionError):
with volume_snapshot("btrfs", "/var/lib/docker", "20260731", run=Busy()):
raise ZeroDivisionError
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,87 @@
"""Contract of the rsync invocation that copies a volume."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from baudolo.backup import volume as mod
class TestBackupVolume(unittest.TestCase):
def copy(self, **kwargs) -> str:
with tempfile.TemporaryDirectory() as tmp:
defaults = {
"versions_dir": tmp,
"volume_name": "demo",
"volume_dir": str(Path(tmp) / "gen" / "demo"),
"authoritative": False,
"source": "/var/lib/docker/volumes/demo/_data/",
}
defaults.update(kwargs)
with mock.patch.object(mod, "execute_shell_command") as run:
mod.backup_volume(
defaults.pop("versions_dir"),
defaults.pop("volume_name"),
defaults.pop("volume_dir"),
**defaults,
)
return run.call_args[0][0]
def test_the_quick_check_pass_carries_no_checksum(self) -> None:
self.assertNotIn("--checksum", self.copy(authoritative=False))
def test_the_authoritative_pass_compares_by_content(self) -> None:
self.assertIn("--checksum", self.copy(authoritative=True))
def test_it_reads_from_the_given_source(self) -> None:
command = self.copy(source="/snapshot/volumes/demo/_data/")
self.assertIn("/snapshot/volumes/demo/_data/", command)
def test_it_always_deletes_what_the_source_no_longer_has(self) -> None:
self.assertIn("--delete", self.copy())
def test_it_creates_the_destination(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp) / "gen" / "demo"
with mock.patch.object(mod, "execute_shell_command"):
mod.backup_volume(
tmp, "demo", str(dest), authoritative=False, source="/src/"
)
self.assertTrue((dest / "files").is_dir())
def test_source_is_required(self) -> None:
with self.assertRaises(TypeError):
mod.backup_volume("/v", "demo", "/d", authoritative=False)
def test_authoritative_is_required(self) -> None:
with self.assertRaises(TypeError):
mod.backup_volume("/v", "demo", "/d", source="/src/")
class TestLastBackupDir(unittest.TestCase):
def test_it_ignores_the_generation_being_written(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
current = Path(tmp) / "20260101" / "demo" / "files"
current.mkdir(parents=True)
found = mod.get_last_backup_dir(tmp, "demo", str(current) + "/")
self.assertIsNone(found)
def test_it_finds_the_previous_generation(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
older = Path(tmp) / "20260101" / "demo" / "files"
older.mkdir(parents=True)
current = Path(tmp) / "20260102" / "demo" / "files"
current.mkdir(parents=True)
found = mod.get_last_backup_dir(tmp, "demo", str(current) + "/")
self.assertEqual(found, str(older) + "/")
def test_a_first_run_has_no_predecessor(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
self.assertIsNone(mod.get_last_backup_dir(tmp, "demo", f"{tmp}/x/"))
if __name__ == "__main__":
unittest.main()

View File

View File

@@ -0,0 +1,44 @@
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from baudolo.restore.db import mariadb as mariadb_mod
class TestMariadbEmptyDrop(unittest.TestCase):
def test_drops_run_in_one_session_with_fk_checks_off(self) -> None:
calls = []
def _capture(container, argv, **kwargs):
calls.append(argv)
result = MagicMock()
result.stdout = b"users\nfetches\n"
return result
with tempfile.NamedTemporaryFile(suffix=".sql") as sql:
sql.write(b"CREATE TABLE t (id int);\n")
sql.flush()
with (
patch.object(mariadb_mod, "docker_exec", side_effect=_capture),
patch.object(mariadb_mod, "_pick_client", return_value="mariadb"),
):
mariadb_mod.restore_mariadb_sql(
container="db",
db_name="mailu",
user="mailu",
password="pw",
sql_path=sql.name,
empty=True,
)
drop_calls = [argv for argv in calls if any("DROP TABLE" in a for a in argv)]
self.assertEqual(len(drop_calls), 1, f"expected ONE drop session: {calls}")
drop_sql = drop_calls[0][-1]
self.assertTrue(drop_sql.startswith("SET FOREIGN_KEY_CHECKS=0; "))
self.assertIn("DROP TABLE IF EXISTS `mailu`.`users`;", drop_sql)
self.assertIn("DROP TABLE IF EXISTS `mailu`.`fetches`;", drop_sql)
self.assertTrue(drop_sql.endswith("SET FOREIGN_KEY_CHECKS=1;"))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,63 @@
import unittest
from baudolo.restore.db.postgres import filter_superuser_only_lines
def _filter(raw: bytes) -> bytes:
return b"".join(filter_superuser_only_lines(raw.splitlines(keepends=True)))
class TestFilterSuperuserOnlyLines(unittest.TestCase):
def test_drops_superuser_only_statements(self) -> None:
raw = (
b"CREATE TABLE t (id int);\n"
b"COMMENT ON EXTENSION pg_trgm IS 'trigram';\n"
b"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO x;\n"
b"INSERT INTO t VALUES (1);\n"
)
self.assertEqual(
_filter(raw),
b"CREATE TABLE t (id int);\nINSERT INTO t VALUES (1);\n",
)
def test_copy_data_rows_are_never_filtered(self) -> None:
raw = (
b"COPY public.snippets (body) FROM stdin;\n"
b"COMMENT ON EXTENSION looks like sql but is data\n"
b"ALTER DEFAULT PRIVILEGES stored as text\n"
b"\\.\n"
b"ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON TABLES FROM y;\n"
)
self.assertEqual(
_filter(raw),
b"COPY public.snippets (body) FROM stdin;\n"
b"COMMENT ON EXTENSION looks like sql but is data\n"
b"ALTER DEFAULT PRIVILEGES stored as text\n"
b"\\.\n",
)
def test_consecutive_copy_blocks_keep_state(self) -> None:
raw = (
b"COPY public.a (v) FROM stdin;\n"
b"row-a\n"
b"\\.\n"
b"COMMENT ON EXTENSION dropme IS 'x';\n"
b"COPY public.b (v) FROM stdin;\n"
b"COMMENT ON EXTENSION kept-as-data\n"
b"\\.\n"
)
out = _filter(raw)
self.assertNotIn(b"dropme", out)
self.assertIn(b"COMMENT ON EXTENSION kept-as-data\n", out)
def test_everything_else_passes_through_verbatim(self) -> None:
raw = (
b"SET statement_timeout = 0;\n"
b"CREATE EXTENSION IF NOT EXISTS pg_trgm;\n"
b"GRANT ALL ON SCHEMA public TO app;\n"
)
self.assertEqual(_filter(raw), raw)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,44 @@
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from baudolo.restore.db import postgres as pg_mod
class TestPostgresSingleTransaction(unittest.TestCase):
def test_replay_is_single_transaction_but_preclean_is_not(self) -> None:
calls = []
def _capture(container, argv, **kwargs):
calls.append(argv)
return MagicMock()
with tempfile.NamedTemporaryFile(suffix=".sql") as sql:
sql.write(b"CREATE TABLE t (id int);\nINSERT INTO t VALUES (1);\n")
sql.flush()
with patch.object(pg_mod, "docker_exec", side_effect=_capture):
pg_mod.restore_postgres_sql(
container="db",
db_name="discourse",
user="discourse",
password="pw",
sql_path=sql.name,
empty=True,
)
self.assertEqual(len(calls), 2, f"expected pre-clean + replay: {calls}")
preclean, replay = calls[0], calls[1]
self.assertNotIn(
"--single-transaction",
preclean,
"pre-clean must stay multi-statement or it exhausts max_locks on large schemas",
)
self.assertIn(
"--single-transaction",
replay,
"dump replay must be atomic so a live concurrent writer cannot trip a duplicate-key abort",
)
if __name__ == "__main__":
unittest.main()

View File

View File

@@ -0,0 +1,215 @@
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
import pandas as pd
from pandas.errors import EmptyDataError
import baudolo.seed.__main__ as seed_main
class TestSeedMain(unittest.TestCase):
@patch("baudolo.seed.__main__.pd.DataFrame")
def test_empty_df_creates_expected_columns(self, df_ctor: MagicMock) -> None:
seed_main._empty_df()
df_ctor.assert_called_once_with(
columns=["instance", "database", "username", "password"]
)
def test_validate_database_value_rejects_empty(self) -> None:
with self.assertRaises(ValueError):
seed_main._validate_database_value("", instance="x")
def test_validate_database_value_accepts_star(self) -> None:
self.assertEqual(seed_main._validate_database_value("*", instance="x"), "*")
def test_validate_database_value_rejects_nan(self) -> None:
with self.assertRaises(ValueError):
seed_main._validate_database_value("nan", instance="x")
def test_validate_database_value_rejects_invalid_name(self) -> None:
with self.assertRaises(ValueError):
seed_main._validate_database_value("bad name", instance="x")
def _mock_df_mask_any(self, *, any_value: bool) -> MagicMock:
"""
Build a DataFrame-like mock such that:
mask = (df["instance"] == instance) & (df["database"] == database)
mask.any() returns any_value
"""
df = MagicMock(spec=pd.DataFrame)
left = MagicMock()
right = MagicMock()
mask = MagicMock()
mask.any.return_value = any_value
# (left & right) => mask
left.__and__.return_value = mask
# df["instance"] / df["database"] => return objects whose == produces left/right
col = MagicMock()
col.__eq__.side_effect = [left, right]
df.__getitem__.return_value = col
return df
@patch("baudolo.seed.__main__.os.path.exists", return_value=False)
@patch("baudolo.seed.__main__.pd.read_csv")
@patch("baudolo.seed.__main__._empty_df")
@patch("baudolo.seed.__main__.pd.concat")
def test_check_and_add_entry_file_missing_adds_entry(
self,
concat: MagicMock,
empty_df: MagicMock,
read_csv: MagicMock,
exists: MagicMock,
) -> None:
df_existing = self._mock_df_mask_any(any_value=False)
empty_df.return_value = df_existing
df_out = MagicMock(spec=pd.DataFrame)
concat.return_value = df_out
seed_main.check_and_add_entry(
file_path="/tmp/databases.csv",
instance="inst",
database="db",
username="user",
password="pass",
)
read_csv.assert_not_called()
empty_df.assert_called_once()
concat.assert_called_once()
df_out.to_csv.assert_called_once_with(
"/tmp/databases.csv", sep=";", index=False
)
@patch("baudolo.seed.__main__.os.path.exists", return_value=True)
@patch("baudolo.seed.__main__.pd.read_csv", side_effect=EmptyDataError("empty"))
@patch("baudolo.seed.__main__._empty_df")
@patch("baudolo.seed.__main__.pd.concat")
@patch("baudolo.seed.__main__.print")
def test_check_and_add_entry_empty_file_warns_and_creates_columns_and_adds(
self,
print_: MagicMock,
concat: MagicMock,
empty_df: MagicMock,
read_csv: MagicMock,
exists: MagicMock,
) -> None:
df_existing = self._mock_df_mask_any(any_value=False)
empty_df.return_value = df_existing
df_out = MagicMock(spec=pd.DataFrame)
concat.return_value = df_out
seed_main.check_and_add_entry(
file_path="/tmp/databases.csv",
instance="inst",
database="db",
username="user",
password="pass",
)
exists.assert_called_once_with("/tmp/databases.csv")
read_csv.assert_called_once()
empty_df.assert_called_once()
concat.assert_called_once()
# Assert: at least one print call contains the WARNING and prints to stderr
warning_calls = []
for call in print_.call_args_list:
args, kwargs = call
if args and "WARNING: databases.csv exists but is empty" in str(args[0]):
warning_calls.append((args, kwargs))
self.assertTrue(
warning_calls,
"Expected a WARNING print when databases.csv is empty, but none was found.",
)
# Ensure the warning goes to stderr
_, warn_kwargs = warning_calls[0]
self.assertEqual(warn_kwargs.get("file"), seed_main.sys.stderr)
df_out.to_csv.assert_called_once_with(
"/tmp/databases.csv", sep=";", index=False
)
@patch("baudolo.seed.__main__.os.path.exists", return_value=True)
@patch("baudolo.seed.__main__.pd.read_csv")
def test_check_and_add_entry_updates_existing_row(
self,
read_csv: MagicMock,
exists: MagicMock,
) -> None:
df = self._mock_df_mask_any(any_value=True)
read_csv.return_value = df
seed_main.check_and_add_entry(
file_path="/tmp/databases.csv",
instance="inst",
database="db",
username="user",
password="pass",
)
df.to_csv.assert_called_once_with("/tmp/databases.csv", sep=";", index=False)
@patch("baudolo.seed.__main__.check_and_add_entry")
@patch("baudolo.seed.__main__.argparse.ArgumentParser.parse_args")
def test_main_calls_check_and_add_entry(
self, parse_args: MagicMock, cae: MagicMock
) -> None:
ns = MagicMock()
ns.file = "/tmp/databases.csv"
ns.instance = "inst"
ns.database = "db"
ns.username = "user"
ns.password = "pass"
parse_args.return_value = ns
seed_main.main()
cae.assert_called_once_with(
file_path="/tmp/databases.csv",
instance="inst",
database="db",
username="user",
password="pass",
)
@patch("baudolo.seed.__main__.sys.exit")
@patch("baudolo.seed.__main__.print")
@patch(
"baudolo.seed.__main__.check_and_add_entry", side_effect=RuntimeError("boom")
)
@patch("baudolo.seed.__main__.argparse.ArgumentParser.parse_args")
def test_main_exits_nonzero_on_error(
self,
parse_args: MagicMock,
cae: MagicMock,
print_: MagicMock,
exit_: MagicMock,
) -> None:
ns = MagicMock()
ns.file = "/tmp/databases.csv"
ns.instance = "inst"
ns.database = "db"
ns.username = "user"
ns.password = "pass"
parse_args.return_value = ns
seed_main.main()
self.assertTrue(print_.called)
_, kwargs = print_.call_args
self.assertEqual(kwargs.get("file"), seed_main.sys.stderr)
exit_.assert_called_once_with(1)
if __name__ == "__main__":
unittest.main(verbosity=2)

61
tests/unit/test_backup.py Normal file
View File

@@ -0,0 +1,61 @@
import unittest
from unittest.mock import patch
from baudolo.backup.policy import requires_stop
@patch("baudolo.backup.policy.is_swarm_task", return_value=False)
class TestRequiresStop(unittest.TestCase):
@patch("baudolo.backup.policy.get_image_info")
def test_requires_stop_false_when_all_images_are_whitelisted(
self, mock_get_image_info, _mock_is_swarm_task
):
mock_get_image_info.side_effect = [
"repo/mastodon:v4",
"repo/wordpress:latest",
]
containers = ["c1", "c2"]
whitelist = ["repo/mastodon:v4", "repo/wordpress:latest"]
self.assertFalse(requires_stop(containers, whitelist))
@patch("baudolo.backup.policy.get_image_info")
def test_requires_stop_true_when_any_image_is_not_whitelisted(
self, mock_get_image_info, _mock_is_swarm_task
):
mock_get_image_info.side_effect = [
"repo/mastodon:v4",
"repo/nginx:latest",
]
containers = ["c1", "c2"]
whitelist = ["repo/mastodon:v4", "repo/wordpress:latest"]
self.assertTrue(requires_stop(containers, whitelist))
@patch("baudolo.backup.policy.get_image_info")
def test_requires_stop_true_on_substring_only_match(
self, mock_get_image_info, _mock_is_swarm_task
):
mock_get_image_info.return_value = "reg:5000/repo/mastodon:v4"
self.assertTrue(requires_stop(["c1"], ["mastodon"]))
self.assertTrue(requires_stop(["c1"], ["repo/mastodon:v4"]))
@patch("baudolo.backup.policy.get_image_info")
def test_requires_stop_true_when_whitelist_empty(
self, mock_get_image_info, _mock_is_swarm_task
):
mock_get_image_info.return_value = "repo/anything:latest"
self.assertTrue(requires_stop(["c1"], []))
class TestRequiresStopSwarm(unittest.TestCase):
@patch("baudolo.backup.policy.get_image_info")
@patch("baudolo.backup.policy.is_swarm_task", return_value=True)
def test_swarm_tasks_never_require_stop(
self, _mock_is_swarm_task, mock_get_image_info
):
mock_get_image_info.return_value = "repo/not-whitelisted:latest"
self.assertFalse(requires_stop(["c1"], []))
mock_get_image_info.assert_not_called()
if __name__ == "__main__":
unittest.main()