Skip to content

Fix backup bugs, harden runtime and add essential features - #34

Merged
itbm merged 9 commits into
masterfrom
claude/wizardly-lovelace-70vymp
Oct 21, 2026
Merged

itbm merged 9 commits into
masterfrom
claude/wizardly-lovelace-70vymp

Conversation

@itbm

@itbm itbm commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

This PR fixes bugs that could lose data or produce silently broken backups, makes the runtime more robust, adds CI, and adds a few essential features. Its commits are listed by phase below.

Phase 1: bug fixes (424d6ae)

  • Empty values are treated as unset. The README's Kubernetes example sets every variable to "". Before this change:
    • DELETE_OLDER_THAN="" deleted every backup older than midnight.
    • S3_ENDPOINT="" broke every aws call.
    • ENCRYPTION_PASSWORD="" encrypted backups with an empty password.
    • SCHEDULE="" stopped the scheduler from starting.
  • COMMAND_TIMEOUT now stops the backup. Previously only sh was killed, and pg_dump and aws kept running. Jobs now run in their own process group, which gets SIGTERM and then SIGKILL.
  • go-cron handles SIGTERM and SIGINT. A running job is terminated cleanly, so temporary files are removed and error hooks fire. Job output is no longer lost.
  • Plain SQL restores stop at the first error (RESTORE_ON_ERROR_STOP, on by default for a single database). Previously a partial restore reported success.
  • Drop and create errors fail the restore. A failed DROP DATABASE now fails the job. CREATE DATABASE runs only when the database is missing. Unsupported formats are rejected before anything is dropped.
  • S3_PREFIX is normalised, so an empty or slashed prefix no longer produces s3://bucket//file.
  • Each run gets its own temporary directory.
  • Early validation. DELETE_OLDER_THAN and PARALLEL_JOBS are checked before any work starts.
  • Reproducible Go build, with go.mod and go.sum committed. common.sh now holds the validation and setup code that was duplicated in the backup and restore scripts.

Phase 2: stability (bdb91be)

  • DELETE_MATCH_DATABASE=yes (opt-in) limits retention to this database's backups, named <POSTGRES_DATABASE>_<timestamp>. The default behaviour is unchanged.
  • Safer retention. The backup uploaded by the current run is never deleted, a failed delete fails the backup, and the log shows how many files were deleted and kept.
  • Dumps are checked before upload. Empty dumps, custom-format dumps that pg_restore --list can't read, and corrupt gzip output are all rejected.
  • New CI workflow, running ShellCheck, shell tests under bash and under BusyBox sh in the built image, gofmt, go vet, go test -race and a Docker build.
  • Dependabot now also covers Go modules and GitHub Actions.

Phase 3: features (b9a7780, ed92a24)

  • *_FILE variables for POSTGRES_USER, POSTGRES_PASSWORD, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY and ENCRYPTION_PASSWORD, so Docker and Kubernetes secrets work.
  • BACKUP_FILE=latest restores the newest backup of the database.
  • Time zones. tzdata is installed, so TZ and CRON_TZ= in SCHEDULE work.
  • BACKUP_ON_START=yes runs a backup when a scheduled container starts.
  • Upload options: S3_STORAGE_CLASS, S3_SSE and S3_SSE_KMS_KEY_ID.
  • One-off runs go through go-cron --once. Stopping the container now stops pg_dump, psql and aws, runs cleanup and error hooks, and keeps the job's exit code.

Phase 4: after rebasing onto the end-to-end suite (302c240, 6e04e9f, be716d8, c5a2f47, 8aa9d02)

This branch is rebased onto master, which now has the end-to-end suite from #35.

  • Plain restores into PostgreSQL 16 and older work again (302c240, c5a2f47, 8aa9d02). pg_dump 17 and later write SET transaction_timeout = 0; into every plain dump, which those servers reject. With stop-on-error on by default, such a restore stopped at the header and restored nothing. For servers older than 17 that line is now dropped where pg_dump writes it, after its other timeout settings. Table data and function bodies are left alone.
  • Empty COMPRESSION_CMD, DECOMPRESSION_CMD and AWS_CA_BUNDLE are treated as unset (be716d8).
  • DELETE_MATCH_DATABASE=yes requires the timestamp (8aa9d02), so a database named app no longer deletes app_archive_… backups.
  • An empty *_FILE secret is an error (8aa9d02). An empty ENCRYPTION_PASSWORD_FILE used to turn encryption off.
  • 31 xfail markers removed (6e04e9f, be716d8). The suite marks 41 known bugs on master; this branch fixes 31 of them, so their tests now pass and check these fixes. The other 10 are fixed in Fix the remaining known bugs from the end-to-end suite #36, which is stacked on this PR.

Behaviour changes to note

  • Plain SQL restores of a single database now fail on SQL errors. For example, restoring into a database that already has the same tables now fails. Use DROP_DATABASE=yes with CREATE_DATABASE=yes, or set RESTORE_ON_ERROR_STOP=no to keep the old behaviour. POSTGRES_DATABASE=all stays permissive by default.
  • A failed DROP DATABASE or retention delete now fails the job instead of only printing a warning.

Testing

  • End-to-end suite (test/e2e, run by CI for each case file): every test passes except the 10 known bugs that Fix the remaining known bugs from the end-to-end suite #36 fixes, which stay marked xfail.
  • Shell tests: sh test/hooks_test.sh runs 152 tests (55 before this PR). They pass under bash, and under BusyBox sh inside the built image. I ran the Phase 1 tests against the original scripts: 17 fail there, which confirms they catch these bugs.
  • Go tests: go test -race passes. The tests cover the process-group kill on timeout, the TERM trap running on shutdown, complete output capture and exit-code mapping.
  • Manual end-to-end checks with postgres:18-alpine and an S3 mock, before the suite existed:
    • plain, encrypted and custom-format backups;
    • backup with an empty prefix;
    • backup using *_FILE secrets;
    • an encrypted custom restore of latest with drop, create and parallel jobs;
    • a plain restore into a non-empty database now fails with exit code 3;
    • retention with DELETE_MATCH_DATABASE kept the unrelated file and the just-uploaded backup;
    • a job blocked on a table lock is killed at COMMAND_TIMEOUT;
    • docker stop during a scheduled, initial or one-off backup terminates pg_dump and fires backup-error;
    • the one-off exit codes (0 and 2) are preserved.

Not included

These would be worth doing later:

  • several databases in one run;
  • streaming uploads that don't use local disk;
  • authenticated encryption (the current format is AES-CBC with no MAC).

🤖 Generated with Claude Code

https://claude.ai/code/session_01CA15ULnH4kuk41vkprNUm1

@itbm
itbm marked this pull request as ready for review September 28, 2026 23:27
itbm pushed a commit that referenced this pull request Oct 20, 2026
- new xfail: a run stopped by COMMAND_TIMEOUT must still run cleanup
  (backup-error script hook and URL, no dump left in /tmp); today
  go-cron SIGKILLs the shell, so its EXIT trap never runs
- '* * * * *' really fires (the only minute-long test)
- @monthly, @Yearly and @annually are accepted
- COMMAND_TIMEOUT does not affect one-off backups, as the README says
- timeout tests match 'timed out after Ns' rather than the full log
  line, so a reworded message still passes
- xfail calls moved to just before the step that exposes each bug

Against PR #34 the file has no failures; its four xfails pass there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdSRPLaq3mTA6Djdzte7C5
itbm pushed a commit that referenced this pull request Oct 20, 2026
New tests:
- S3_BUCKET with a sub-path (the README calls it a bucket path): backup
  key, BACKUP_S3_URI, retention and restore all work
- xfails for S3_PREFIX with a trailing slash ('backups/'): every backup
  fails against MinIO (XMinioInvalidObjectName) because the key has
  '//', and AWS S3 would store such keys
- xfails for a leading slash ('/backups'): the request path and
  BACKUP_S3_URI contain '//', and retention lists a prefix that matches
  nothing, so the backup exits 2 after uploading
- the custom-region trace now also covers retention's list and delete

Removes four tests duplicated in validation.sh, retention.sh, smoke.sh
and backup.sh. Against PR #34 the file has no failures; its seven
prefix xfails pass there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdSRPLaq3mTA6Djdzte7C5
itbm pushed a commit that referenced this pull request Oct 20, 2026
- an encrypted POSTGRES_DATABASE=all backup is stored only as
  all_<ts>.sql.gz.enc, decrypts with the README's command, and restores
  both databases, the role and its ownership into a fresh server
- tests no longer pin the temporary file in the 'Encrypting ...' log
  line; the .enc object, its Salted__ header and the round trips prove
  encryption
- the encryption-failure test uses a stand-in openssl that fails 'enc',
  instead of blocking a fixed temporary path
- the legacy xfail is renamed for the behaviour it expects, and xfail
  calls sit just before the step that exposes each bug

All three tests that failed against PR #34 only because of the old
temporary path now pass on both images.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdSRPLaq3mTA6Djdzte7C5
itbm pushed a commit that referenced this pull request Oct 20, 2026
- the error-event scrubbing test now also covers backup-error, with
  ENCRYPTION_PASSWORD, AWS_SESSION_TOKEN and AWS_ROLE_ARN set, and
  HOOK_EXIT_CODE=2
- new test: a directory at $HOOKS_DIR/<event> (which passes a plain -x
  check) fails the job closed with nothing uploaded
- the scheduled-hooks test judges only events recorded before it stops
  the container. On PR #34 docker stop forwards SIGTERM to the running
  backup, which then correctly fires backup-error; the old check failed
  whenever the stop landed mid-run
- the HOOK_INHERIT_ENV xfail moves to just before the step that exposes
  the bug

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdSRPLaq3mTA6Djdzte7C5
itbm pushed a commit that referenced this pull request Oct 20, 2026
- new test: with S3_CA_BUNDLE and verification on, DELETE_OLDER_THAN
  deletes an old object on minio-tls and keeps a newer one and the new
  backup (only S3_SSL_VERIFY=no exercised retention over TLS before)
- renames the explicit S3_SSL_VERIFY=yes test for what it proves
- drops per-test database clean-up helpers now that run.sh does it

Passes on both the master and PR #34 images.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdSRPLaq3mTA6Djdzte7C5
- Treat empty variables as unset. The README's Kubernetes example sets
  values to "", which made DELETE_OLDER_THAN delete every backup older
  than midnight, broke S3_ENDPOINT, enabled encryption with an empty
  password and stopped SCHEDULE from starting.
- Move shared validation and AWS/PostgreSQL setup into common.sh.
- Normalise S3_PREFIX so an empty or slashed prefix no longer produces
  keys such as s3://bucket//file.
- Validate DELETE_OLDER_THAN and PARALLEL_JOBS before any work starts.
- Use a per-run temporary directory so concurrent runs cannot collide.
- Stop plain SQL restores at the first error (RESTORE_ON_ERROR_STOP),
  fail when DROP DATABASE fails, create the database only when it is
  missing, and reject unsupported formats before dropping anything.
- go-cron: run jobs in their own process group so COMMAND_TIMEOUT
  kills pg_dump and aws too, keep all job output, and shut down
  gracefully on SIGTERM/SIGINT so cleanup and error hooks run.
- Pin the Go dependency with go.mod/go.sum and add Go unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA15ULnH4kuk41vkprNUm1
- Add DELETE_MATCH_DATABASE (default no, keeping current behaviour) so
  DELETE_OLDER_THAN can be limited to <POSTGRES_DATABASE>_* files.
- Never delete the backup uploaded by the current run, fail the backup
  when a delete fails, and log how many files were deleted and kept.
- Check each dump before upload: reject empty dumps, custom-format
  dumps that pg_restore --list cannot read, and corrupt gzip output.
- Add a CI workflow for pushes and pull requests: ShellCheck, shell
  tests under bash and BusyBox sh in the built image, gofmt, go vet,
  go test -race and a Docker build.
- Extend Dependabot to Go modules and GitHub Actions.
- Fix ShellCheck warnings in the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA15ULnH4kuk41vkprNUm1
- Read POSTGRES_USER, POSTGRES_PASSWORD, S3_ACCESS_KEY_ID,
  S3_SECRET_ACCESS_KEY and ENCRYPTION_PASSWORD from *_FILE variables
  for Docker and Kubernetes secrets.
- BACKUP_FILE=latest restores the newest <POSTGRES_DATABASE>_<timestamp>
  backup in S3_PREFIX. Hooks see the resolved file name.
- Install tzdata so TZ and CRON_TZ in SCHEDULE work.
- BACKUP_ON_START=yes runs a backup when a scheduled container starts.
- S3_STORAGE_CLASS, S3_SSE and S3_SSE_KMS_KEY_ID set upload options.
- Run one-off backups and restores through go-cron --once, so stopping
  the container stops pg_dump, psql and aws, runs cleanup and error
  hooks, and keeps the job's exit code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA15ULnH4kuk41vkprNUm1
The runner's ShellCheck does not support the busybox dialect, so the
Shell scripts job failed with "Unknown shell: busybox". Install
shellcheck-py 0.11.0.1 in a venv and run that binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA15ULnH4kuk41vkprNUm1
@itbm
itbm force-pushed the claude/wizardly-lovelace-70vymp branch from 62aad10 to ed92a24 Compare October 20, 2026 20:38
pg_dump 17 and later write "SET transaction_timeout = 0;" into the header
of every plain dump. PostgreSQL 16 and older reject that setting, so with
ON_ERROR_STOP now on by default, restoring such a dump into an older
server stopped at the header and nothing was restored.

For servers older than 17, drop that line from each dump header before
psql reads it. Table data and later statements are passed through as
they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdSRPLaq3mTA6Djdzte7C5
The end-to-end suite from master marks 41 known bugs as xfail. With this
branch, 28 of those tests pass, so the run reported them as XPASS. Their
markers are removed so the suite now checks these fixes. The other 13
are still open and keep their markers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdSRPLaq3mTA6Djdzte7C5
An empty COMPRESSION_CMD left a bare pipe, so every plain backup exited
141. An empty DECOMPRESSION_CMD made restore.sh try to run the downloaded
file. An empty AWS_CA_BUNDLE made the AWS CLI skip certificate checks.
Fall back to gzip and "gunzip -c", and unset an empty AWS_CA_BUNDLE.
Remove the end-to-end xfail markers for these three cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA15ULnH4kuk41vkprNUm1
POSTGRES_EXTRA_OPTS is passed to pg_dump and psql alike, and -x means
"no privileges" to pg_dump but expanded output to psql. With -x, psql
prints the version as "server_version_num|160015", the comparison
failed, and a plain restore into PostgreSQL 16 or older kept the
transaction_timeout line and stopped there.

Take the value after the last "|", and leave the dump unchanged when
the version is not a number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdSRPLaq3mTA6Djdzte7C5
Fixes found by reviewing this branch:

- The transaction_timeout filter now drops the line only where pg_dump
  writes it: right after its statement_timeout, lock_timeout and
  idle_in_transaction_session_timeout settings. The header tracking it
  replaces could also drop that line from a function body or --inserts
  data after a line starting with \connect, and it missed sections of
  pg_dumpall output from pg_dump 17.0-17.5, which has no \restrict
  line. The unit test now checks the exact output for these cases.
- DELETE_MATCH_DATABASE=yes deleted other databases' backups whose
  names start with "<POSTGRES_DATABASE>_", such as app_archive_... for
  a database named app. It now requires the timestamp, using the same
  check as BACKUP_FILE=latest.
- An empty *_FILE secret is an error. An empty ENCRYPTION_PASSWORD_FILE
  turned encryption off and uploaded a plain backup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdSRPLaq3mTA6Djdzte7C5
@itbm
itbm merged commit e97fb15 into master Oct 21, 2026
17 checks passed
@itbm
itbm deleted the claude/wizardly-lovelace-70vymp branch October 21, 2026 06:56
@itbm
itbm restored the claude/wizardly-lovelace-70vymp branch October 21, 2026 06:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants