Mirror the SQLite backups to an off-site target (#18) #123
No reviewers
Labels
No labels
area/ai
area/backend
area/frontend
area/infra
area/scheduler
area/wled
good-first-issue
priority/high
priority/low
priority/medium
type/bug
type/chore
type/ci-cd
type/docs
type/feature
type/qa
v1.0.0
v1.1.0
v1.2.0
v2.0.0
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/Iris-WLED!123
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/18-offsite-backup"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #18.
The existing snapshots live on the same volume as the database they protect — a defence against a bad import, not against losing the host. Two targets, both off by default: a mounted directory (
BACKUP_REMOTE_PATH) for a NAS or second disk, and any S3-compatible endpoint (BACKUP_S3_*) for MinIO, Backblaze or AWS. Restore is documented indocs/troubleshooting.md.Credentials are environment-only and never enter the settings table, so they can't reach the config bundle from #23 — a file whose whole purpose is to be committed to a repository.
A real bug in my first draft
Remote retention originally matched on an
"iris_"filename prefix. Every daily backup is namediris_daily_..., so that prefix also matches the dailies — manual pruning would have deleted daily backups early. It now shares the exact_KIND_GLOBSpattern local pruning uses, deliberately the same rule rather than a parallel one that has to be kept in step. There's a test for it.Why no boto3
S3 here is three operations — PUT, list, DELETE — and boto3 with botocore is ~50MB in an image we deliberately slimmed in #60. So the SigV4 signature is computed against httpx directly.
That's normally a bad trade, and it's worth being explicit about why it's acceptable here: SigV4 is request authentication, not encryption. There's no key exchange and no confidentiality property to get subtly wrong — a mistake produces a 403 and a loudly broken feature, not a silently insecure one.
Two things the review caught, both mine
The security hook flagged stdlib XML parsing, correctly. S3 listing bodies are remote input and
xml.etreeexpands entities, so a hostile or compromised endpoint could answer with a billion-laughs payload. I'd argued against dependencies for this feature — butdefusedxmlis ~30KB against boto3's ~50MB, so refusing that would have been false economy. Added, plus a size bound on the body.I asserted a SigV4 test vector from memory and it was wrong. The test failed; I "fixed" it by parameterising the service to match AWS's
iamexample, and it failed again — because the hex constant itself was fabricated. I then fetched AWS's actual documentation: it states the derivation as an algorithm and gives no worked numeric example, so there is no vendor vector to cite.The chain I implemented matches the spec exactly (
AWS4+secret → date → region → service → aws4_request), but the test now reimplements those four steps longhand from the specification rather than calling the module and comparing it to itself. I removed theserviceparameter that existed only to serve the bogus test, and confirmed the remaining test catches a region/service swap.I'd rather say plainly that there's no vendor vector than leave a comment claiming an authority the test doesn't have.
Failure behaviour
An unreachable target never fails a backup. The local snapshot is already written and is what actually stands between the user and data loss, so a dead NAS is logged and the bulk operation that triggered it still completes. That's documented in troubleshooting, along with the corollary: check the logs rather than assuming silence means success.
Uploads to a mounted path go to a staging name and are renamed, so a reader never sees a half-copied backup that looks complete.
Verification
ruff,mypycleanuvx pre-commit run --all-filespassesNothing here touches the controller or the frontend.
🤖 Generated with Claude Code