Skip to content

feat(backups): settings-driven database backups that fail loudly - #288

Merged
roncodes merged 2 commits into
release/v1.6.69from
feature/database-backups
Oct 7, 2026
Merged

roncodes merged 2 commits into
release/v1.6.69from
feature/database-backups

Conversation

@roncodes

@roncodes roncodes commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Why

The fleetbase-db-backups bucket only ever received four 20-byte empty gzip files, written on 2026-09-24/25 by a local dev stack's scheduler. Production's scheduler has run db:backup nightly and logged DONE without writing anything. The old command made every one of these failures invisible:

  • No pipefail. It ran mysqldump … | gzip > file without pipefail. Neither image has mysqldump, so gzip compressed empty input into 20 bytes and exited 0.
  • Upload errors swallowed. It caught MultipartUploadException and only mentioned it in verbose mode. Production's task role can't PutObject to the bucket, and nobody saw that.
  • Silent exit. A failed dump made it return; with exit code 0.
  • Unconditional retention. Retention ran after every upload, good or not.

What

  • DatabaseBackupService
    • Runs the dump client without a shell and streams its stdout into gzip in PHP, so no pipeline can hide its exit code. The password goes in MYSQL_PWD, not on the command line.
    • A run fails if:
      • the dump client exits non-zero,
      • the output lacks the -- Dump completed marker,
      • the compressed file is under min_size_bytes, or
      • the uploaded object's size differs from the local file.
    • Uploads go to any filesystem disk (with a bucket override for s3) under a key prefix, using the same file naming as before.
    • Retention (by days and/or count) runs only after every database in the run succeeded, and always keeps each database's newest backup.
    • A cache lock prevents overlapping runs.
    • On failure it emails the configured addresses (DatabaseBackupFailed) and writes Log::error.
  • database_backups table and DatabaseBackup model: one row per database per run, with status, trigger, disk/path, size, duration, error and when it was pruned. Rows are pruned after a year.
  • DatabaseBackupSettings:
    • The environment defaults are in config/database-backups.php (DB_BACKUP_*); the admin override is stored as system.database-backups.
    • DatabaseBackupSettings::schedule() registers db:backup --trigger=scheduled on the configured cron (UTC), and only while enabled.
    • Disabled by default.
  • db:backup:
    • Exits non-zero if any database fails.
    • --force runs it while backups are disabled.
    • --connection=* limits it to specific connections.
    • --trigger= records what started the run.
  • DatabaseBackupController (AdminRequest), under int/v1/database-backups:
    • GET/POST/DELETE settings
    • GET runs?limit= (newest first)
    • POST run (202, queues RunDatabaseBackup)
  • Removed: MysqlS3Backup, S3BackupTrimmer and config/laravel-mysql-s3-backup.php.

Deploy notes

  • Image: the app image needs a dump client. The companion fleetbase/fleetbase PR adds default-mysql-client (MariaDB mysqldump, which works against MySQL 8.0).
  • Remove the internals schedule: fleetbase/internals still schedules db:backup daily. With this change it would be a no-op while backups are disabled, but it must be removed to avoid a second daily run once they're enabled. There is a companion PR.
  • Grant S3 write access: the production task role (task-92b1ceb) has only s3:ListBucket on fleetbase-db-backups. It needs s3:PutObject, s3:GetObject and s3:DeleteObject on arn:aws:s3:::fleetbase-db-backups/*. See the runbook in the fleetbase/fleetbase PR.
  • Enable backups: after deploying, turn them on in Admin → Database Backups (or set DB_BACKUP_ENABLED=true) and pick the s3 disk with bucket fleetbase-db-backups.

Tests

tests/Unit/DatabaseBackupsTest.php runs the real service with a PHP one-liner standing in for mysqldump, so streaming, compression, the completion-marker and size checks, upload, retention, locking and notification all execute for real against a local disk. It also covers the command, the job, the notification, the model, the controller (including validation) and the route contract.

Not run locally (per the repo owner's no-local-builds rule); CI is the verification.

Replace the db:backup command, which piped mysqldump through gzip without
pipefail, swallowed upload errors and returned success on a failed dump.
That is how production uploaded 20-byte empty dumps on 2026-09-24/25 (no
mysqldump in the image) and then nothing at all while reporting DONE.

- DatabaseBackupService streams the dump client's stdout into gzip in PHP
  (no shell pipeline), passes the password via MYSQL_PWD, and fails a run
  on a non-zero exit, a missing '-- Dump completed' marker, a dump under
  min_size_bytes, or an uploaded object whose size differs from the file.
- Uploads go to any filesystem disk (bucket override for s3, key prefix);
  retention by age and/or count runs only after a fully successful run and
  always keeps each database's newest backup.
- Every attempt is recorded in database_backups (status, size, duration,
  error, trigger); failures can email configured addresses.
- Settings live in system.database-backups (env defaults in
  config/database-backups.php) and drive the schedule; disabled by default.
- Admin endpoints under int/v1/database-backups: settings get/save/reset,
  recent runs, and a queued 'run now'.
- db:backup exits non-zero on any failure; --force runs while disabled.
@roncodes roncodes mentioned this pull request Oct 7, 2026
@roncodes
roncodes changed the base branch from main to release/v1.6.69 October 7, 2026 05:51
@roncodes
roncodes marked this pull request as ready for review October 7, 2026 05:53
@roncodes
roncodes merged commit dcaeaed into release/v1.6.69 Oct 7, 2026
4 of 5 checks passed
@roncodes
roncodes deleted the feature/database-backups branch October 7, 2026 05:53
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.

1 participant