github preston-peterson/aerodrome v3.4.0

latest releases: v3.4.136, v3.4.135, v3.4.134...
5 months ago

Server-side backup and restore for databases too large for the browser, plus streaming uploads everywhere. Surfaced during a real production restore: a 2.18 GB backup was rejected by /api/backup/import because of a hardcoded 2 GB cap, and even with the cap raised the underlying mechanism — buffering the entire upload into Python memory before processing it — wouldn't scale. The 2 GB cap was a sanity check from a time when "real Aerodrome users with year-plus retention" wasn't a category that existed yet. v3.4.0 rebuilds the backup/restore architecture around the working assumption that databases can grow into the hundreds of GB and the browser is not always a viable transport.

Two new code paths, one zip format. The existing browser-mediated flow (Download full backup button → upload to Restore from backup) keeps working for small databases — same zip format, same restore semantics, same .pre-restore safety snapshots. Added next to it: a server-side flow that writes the same zip directly to <install_dir>/.backups/aerodrome-backup-<timestamp>.zip on the host, with a sidecar .sha256 file, and provides an in-UI list with restore/copy-scp-command/delete actions. The two paths produce bit-identical backup files — you can scp a server-side backup onto a different host and use the browser-restore flow against it, or vice versa. One mental model, two transports.

The streaming-uploads fix is independent and applies to both paths. Before v3.4.0, POST /api/backup/import called await file.read() to slurp the entire upload into a Python bytes object, then wrapped it in a BytesIO for zipfile.ZipFile to read from. A 2 GB upload meant 2 GB of process RSS just to check it was valid. On a Pi-class host this could OOM the process before the size check fired. v3.4.0 streams the upload in 1 MB chunks to <install_dir>/.backups/.uploads/upload-<ts>-<pid>.zip, then processes the zip from disk. Peak memory is the chunk size, not the upload size. The 2 GB hardcoded cap is gone; replaced with an honest disk-space pre-check (need bytes_written + current_db_size + 256 MB headroom, returns HTTP 507 with a clear message if insufficient). The export side got the same streaming treatment — the zip is built on a temp file under .backups/ and streamed out in 1 MB chunks rather than living in a 100 GB BytesIO.

Out-of-band transfer pattern documented. A 50 GB backup zip doesn't belong in a browser session. The server-side flow's "Copy scp command" button templates the right invocation from the user's view of the host:

scp user@aerodrome-host:/opt/aerodrome/.backups/aerodrome-backup-20260512-150658.zip ./

Users edit the username if needed and run from their laptop or backup destination. For automated off-host backup, the recommended pattern in docs/INSTALL.md is a separate machine's cron running rsync against the .backups/ directory daily — works at any database scale and decouples backup retention from Aerodrome's awareness.

Five new endpoints in server.py:

  • POST /api/backup/create-server-side — kicks off a backup, returns when complete with {ok, path, filename, bytes, sha256, duration_seconds, manifest}. Uses SQLite's online backup API so writes continue against the live DB during the snapshot. Disk-space pre-check rejects backups that would exhaust the host. Concurrency-locked via a module-level _server_side_backup_state dict — only one server-side backup at a time, second attempt returns HTTP 409 with current progress.
  • GET /api/backup/create-server-side/progress — poll endpoint for the running backup, returns {running, phase, percent, bytes_written, path}. Driven by a progress_callback(phase, percent, bytes_written) threaded through the new shared _build_backup_zip() helper so phase reporting is honest (manifest → config → db_snapshot → db_zip → db_zipped → done).
  • GET /api/backup/list-server-side — lists user-initiated server-side backups under .backups/aerodrome-backup-*.zip (NOT the auto-managed .pre-restore.* snapshots — those have their own endpoint). For each: filename, full path, bytes, mtime, sha256 (read from sidecar; computed once at backup time, not re-hashed on every list). Sorted newest-first.
  • POST /api/backup/restore-from-path — restores from a backup file already on disk. Body: {path}. Path must resolve inside <install_dir>/.backups/ by default; admin who scp'd a file from a remote backup destination drops it there first. Disk-space pre-check matches the upload path. Calls the same _restore_from_local_zip() helper as the streaming-upload path, so restore semantics are identical regardless of transport. Source file is NOT deleted on success — the user explicitly chose to keep a backup on disk by using server-side restore.
  • DELETE /api/backup/server-side/{filename} — removes a named backup + its .sha256 sidecar. Filename validation rejects path separators, .., anything not matching aerodrome-backup-*.zip. Resolved path must remain inside .backups/.

Shared backup-build helper. The existing /api/backup/export and the new /api/backup/create-server-side both call _build_backup_zip(install_dir, dest_path, progress_callback=None), which writes the zip incrementally to dest_path (a filesystem path string). The browser-export path passes a temp file under .backups/ and streams it back to the client; the server-side path writes directly to the final destination. Same code, same zip layout, same SQLite snapshot dance — just different destination semantics. The only DB-sized buffer in either path is the SQLite .backup temp file, which gets removed after zf.write reads it.

Shared restore helper. Both /api/backup/import and /api/backup/restore-from-path call _restore_from_local_zip(zip_path, install_dir, source_label, cleanup_source_on_success), which reads the backup from a local filesystem path (no memory buffering, no upload assumptions). The cleanup_source_on_success parameter is True for uploads (the staged temp file is no longer needed once restore succeeds) and False for server-side paths (the user's backup file should survive past the restore). Restore semantics are otherwise identical: manifest validation, wrapper-folder detection, .pre-restore.<timestamp> safety snapshots, ntfy auto-install on not_installed state (the v3.3.1 fix), service restart at the end.

UI integration in templates/config.html. New "Server-side backup" section between "Full backup" and "Restore safety snapshots". Three subsections inside it: "Create new server-side backup" with progress display, "Existing server-side backups" with the list + actions, "Restore from a path on disk" with a path input for the edge case where the user has a backup zip somewhere outside .backups/ and wants to restore from it directly (must move it into .backups/ first; the helpful error message in the API tells them so). The list polls /api/backup/list-server-side and shows each backup with filename, size in MB, mtime in local time, sha256 prefix, and three buttons: "Restore from this" (destructive-confirm modal then call /api/backup/restore-from-path), "Copy scp command" (templates from window.location.hostname), "Delete" (destructive-confirm then DELETE /api/backup/server-side/<filename>). The create button polls /api/backup/create-server-side/progress every second so users see live phase (N%, X.X MB written) updates during long backups.

Documentation in docs/INSTALL.md. New "Server-side backup (v3.4.0)" subsection under "Backup and restore" with: when to use which flow (table by database size), the scp out-of-band recipe, the rsync-from-cron recipe for automated off-host backup, the destructive-confirm restore-from-path walkthrough. Honest about the limits: above ~50 GB, filesystem-level snapshots (ZFS, btrfs, LVM) or WAL shipping become the right pattern; v3.4.0's server-side flow is the building block but not the complete answer at that scale.

Why this is v3.4.0 rather than v3.3.2. The streaming-uploads fix alone would be a patch — bug fix, internal-implementation, no new surface. The server-side backup flow is a new feature with five new endpoints, a new UI section, and a new operational pattern (out-of-band transfer). New capability across the user-visible surface is a minor bump per semver. Notably no breaking changes — the existing browser-mediated flow keeps working identically, and the zip format is unchanged.

Operational note: Aerodrome installs whose database exceeds the old 2 GB cap (or is approaching it) should adopt the server-side flow as the default. Browser-mediated full backup keeps working but is increasingly impractical above ~2 GB. The "Copy scp command" button + a cron-rsync from a separate machine is what real-scale backup looks like for an Aerodrome that grows into the year-plus-retention regime. 730M-row scale (the worst-case bound roughly equivalent to 180 GB on disk) is the architecture target, not the validated target — that level of validation requires real-world data accumulation over weeks and is outside what any code review can cover.

Scope:

  • server.py — new _build_backup_zip() and _restore_from_local_zip() helpers (~150 lines), refactor of backup_export to use the helper via temp file + 1MB-chunk yield (~30 lines net), refactor of backup_import to stream uploads to disk + disk-space check + delegate to restore helper (~45 lines net), five new endpoints (~250 lines)
  • templates/config.html — new Server-side backup section (~75 lines HTML) + JS for create/list/restore-from-path/delete + scp-command-copy helper (~165 lines)
  • docs/INSTALL.md — new "Server-side backup (v3.4.0)" subsection (~60 lines) under existing Backup and restore section
  • No schema changes, no config-key changes, no sudoers changes. SUDOERS_VERSION unchanged at 4.

Don't miss a new aerodrome release

NewReleases is sending notifications on new releases.