Back up and restore plain directories, alongside the database sources.
Files source and Archive (P0)
- `files` type: absolute path (not /), options.exclude and
options.one_file_system; walk + tar.gz with symlinks kept as links,
special files skipped, unreadable entries fail the run.
- Mirror restore: archive validated first, then everything not excluded
is wiped (mount points and special files kept) and the archive is
extracted, retrying without metadata on filesystems refusing chown/chmod.
- Windows-safe platform helpers; helm extraVolumes/extraVolumeMounts.
Universal rclone mapper (P1)
- rclone_target(storage) for s3, blob, gcs, drive, rclone and sftp; sftp
goes through it; rclone obscure reads the password from stdin.
Snapshots, restic (P2)
- restic in the image; repository per source and channel, password
derived from the master key (HKDF); stable host, bs:<row> tags,
translated excludes; exit 3 fails and forgets the snapshot.
- Mirror restore with `restic restore --delete`; restore payload carries
the encrypted channel; snapshot ids must be full ids.
- Engine in the ping and in scheduled task metadata.
One locked method per source (spec A)
- databases.json declares options.method (archive, snapshot, sync),
reported in the ping for local sources only.
- Sync: rclone sync per channel onto <folder>/sync/<id>/current, refuses
an empty source, reports transferred/deleted counters.
Local channel (spec B)
- Snapshots and Sync reach the dashboard's local storage through its
append-only restic REST server and its WebDAV server.
Security
- Strict rclone config validation: one section, no duplicate keys, no
command, credential or host-file keys, no quoted values, valid backend
type, blocked backends.
- Scheduled task metadata (decrypted storages) is no longer logged.
helm/templates/env-configmap.yaml never listed RETRY_ATTEMPTS and
RETRY_BACKOFF_MS even though values.yaml gained them, so --set
env.RETRY_ATTEMPTS=N was silently ignored by Kubernetes deployments.
Add both keys in the same explicit style as the existing entries.
src/utils/retry.rs logged its own "failed after N attempts" error on
exhaustion, on top of the terminal log each call site already writes,
producing two error entries per failure. Worse, it changed a log
level: FileLock::acquire's "backup_already_in_progress" bails through
the combinator, which now logged it as error before runner.rs got a
chance to reclassify it as the routine warn it always was. A manual
backup colliding with a scheduled one would show up as a hard error
on the dashboard instead of the harmless warn it used to be, breaking
the "fails exactly as it does today" guarantee for job records.
Drop the combinator's terminal error log and give download_backup its
own terminal error log so all three call sites (runner, uploader,
downloader) own their failure logging uniformly. Update the two tests
that asserted the removed message to assert the new behavior instead.
Extracts the download body to download_once and makes download_backup a
retry wrapper around it. This path had no retry at all before, so a
single dropped connection failed the whole restore job.
Retrying is safe because File::create truncates and the target filename
is derived from Content-Disposition or the URL, so it is stable across
attempts. There is no Range resume: a download that fails at 90% starts
over.
Wraps provider.upload in the retry combinator. No provider changes are
needed: each one builds its upload stream from the file on disk inside
upload(), so every attempt gets a fresh handle and a fresh nonce.
Result<UploadResult, UploadResult> is collapsed with an or-pattern so
the last attempt's error and metadata survive into the existing failure
branch. A missing backup file short-circuits into Err rather than
returning early, which skips the retry without skipping the
backup_upload_status(failed) call that closes the server-side record.
backup_upload_init and backup_upload_status are left unwrapped; they
are control-plane calls, not storage uploads.
Each attempt now dumps into its own tmp_path/attempt-{n} directory,
which is removed when the attempt fails. Without the per-attempt
directory pg_dump -Fd would refuse every retry, because it will not
write into a directory a previous attempt left behind; removing it on
failure keeps peak disk at one attempt's artifacts rather than five.
A backup blocked by a concurrent job is retried before surfacing the
same backup_already_in_progress code, since FileLock reports it as an
ordinary error and the combinator cannot tell it apart.
Reshapes retry()'s bound from the native AsyncFnMut sugar to the
classic F: FnMut(u32) -> Fut, Fut: Future<Output = Result<T, E>> + Send
shape (the pattern tokio-retry and backoff both use). AsyncFnMut's
produced future is a lifetime-quantified associated type
(F::CallRefFuture<'_>) that cannot be named or bounded as Send on
stable Rust, so wiring a retried call through it into a future that
eventually gets polled inside tokio::spawn (dispatcher.rs, via
execute_backup) made rustc's opaque-type Send inference fail with
"implementation of Send is not general enough" at the spawn site,
several call layers away from the actual retry call. Naming Fut as its
own type parameter lets Send be asserted on it directly instead, which
resolves cleanly. The combinator's control flow, log messages, and
formats are unchanged; only the bound and the call sites' closure
shape (async |x| { } becomes |x| async { }, with shared references
bound outside a move closure so the inner async move block only moves
Copy references, not the originals) are affected.
Adds RETRY_ATTEMPTS (3..=5, default 3) and RETRY_BACKOFF_MS
(100..=30000, default 1000) to Settings, validated with the same
panic-on-invalid contract as POOLING and CHUNK_SIZE_MB.
The combinator logs every failed attempt and any late success through
the JobLogger it borrows, so retries reach the server on the existing
job-log path with no API change. It borrows rather than clones the Arc
so Arc::try_unwrap in the backup executor keeps working. Backoff is
exponential with equal jitter, because the uploader retries storages
concurrently and would otherwise retry them in lockstep.
Add `options: HashMap<String, serde_json::Value>` to DatabaseConfig and
`options: Option<HashMap<...>>` to InputDatabaseConfig so callers can
pass arbitrary per-database options (e.g. keep_ownership for pg_dumpall).
Update all DatabaseConfig struct literals in test files and add three
new config-service unit tests covering present, absent, and non-bool values.
* fix: refactoring
* chore: add google-cloud-storage and google-cloud-auth deps
* feat: add GCS provider config model
* feat: add GCS credential, client, and stream-source helpers
StreamSource bridges build_stream's Send-only byte stream into the SDK's
StreamingSource (which send_buffered requires to be Send+Sync+'static) via a
bounded mpsc channel, avoiding any change to the shared UploadStream type.
* feat: implement GCS StorageProvider upload
* feat: register google-cloud-storage provider in factory
* test: GCS upload roundtrip against fake-gcs-server
* fix: format GCS bucket as projects/_/buckets/<name> in upload_with_client
write_object rejects the bare bucket id with "malformed bucket name"; the
production provider passed config.bucket_name unformatted, so real uploads
would always fail. Format once in upload_with_client so prod and the
fake-gcs-server test share the corrected path.
* style: cargo fmt GCS provider files
* fix: gcs