Backups: a daily dedupe-friendly tar to a folder or S3, with restore (#5918-#5921)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m38s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m5s
Desktop (Tauri) / Update manifest (push) Successful in 3s

#5918 "Backup file": an uncompressed tar of per-table JSON Lines (primary-key
order, sorted keys) plus every attachment file, with fixed tar headers, a
dated name, and an atomic write through a dot-named .part file. Unchanged
rows are the same bytes every day, so restic off-site stores only the edits.
No credential ever enters a backup.

#5919 "Backup destinations": a folder, or an S3-compatible bucket through a
stdlib SigV4 put/list client (checked against AWS's published signatures),
with an upload limit. Test destination only lists, so an append-only key works.

#5920 "Backup job": daily at a chosen hour, Back up now, a 1/2/4 h retry
ladder, a one-hour deadline, an advisory lock, the last 30 runs, and an
overdue verdict on Settings -> Backups.

#5921 "Restore": python -m inkwell.restore FILE into an empty instance, an
integration test that backs up and restores, and docs/backups.md with
Fabled Archives as the worked example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 13:54:51 -04:00
co-authored by Claude Opus 5.5
parent c0de50d85d
commit edcb5c4c11
18 changed files with 2069 additions and 13 deletions
+96
View File
@@ -2099,3 +2099,99 @@ async def test_the_test_email_goes_to_the_admin_and_reports_a_failure(app_client
failed = await app_client.post("/api/settings/test-email")
assert failed.status_code == 502
assert "bad credentials" in (await failed.get_json())["error"]
# --- backups (#5918–#5921) --------------------------------------------------------------
async def _backup_settings_off() -> None:
"""Settings outlive the per-test truncate, so each backup test starts from none."""
async with session_scope() as fresh:
await fresh.execute(delete(Setting).where(Setting.key.like("backup%")))
await fresh.execute(delete(Setting).where(Setting.key == "smtp_password"))
await fresh.commit()
async def test_a_backup_is_written_to_a_folder_and_restores_into_an_empty_instance(app_client, db, tmp_path):
import tarfile
from inkwell import backup_job
from inkwell.restore import restore
await _backup_settings_off()
await _admin_with_invite(app_client)
note = await (await app_client.post("/api/notes", json={"body": "buy milk #grocery"})).get_json()
uploaded = await app_client.post(f"/api/notes/{note['id']}/attachments", files=_upload(b"photo bytes", "p.bin"))
assert uploaded.status_code == 201
folder = tmp_path / "backups"
folder.mkdir()
saved = await app_client.patch(
"/api/settings",
json={"backup_destination": "folder", "backup_folder": str(folder), "smtp_password": "hunter2"},
)
assert saved.status_code == 200, await saved.get_data(as_text=True)
checked = await app_client.post("/api/backups/test")
assert checked.status_code == 200, await checked.get_data(as_text=True)
assert "writable and holds 0 Inkwell backups" in (await checked.get_json())["message"]
assert (await app_client.post("/api/backups/run")).status_code == 202
await backup_job.drain()
status = await (await app_client.get("/api/backups")).get_json()
run = status["runs"][0]
assert run["ok"] is True, run
assert run["files"] == 1 and run["missing_files"] == 0
path = folder / run["name"]
assert path.is_file() and not list(folder.glob(".*"))
# No credential travels in a backup.
with tarfile.open(path) as tar:
settings_rows = tar.extractfile("tables/settings.jsonl").read().decode()
names = tar.getnames()
assert "hunter2" not in settings_rows and '"secret_key"' not in settings_rows
assert '"backup_folder"' in settings_rows
assert "tables/password_resets.jsonl" not in names
# A different instance: the database emptied and the attachments gone.
async with session_scope() as fresh:
await fresh.execute(text(f"TRUNCATE {_TABLES}, device_tokens RESTART IDENTITY CASCADE"))
await fresh.commit()
for f in Config.media_root().rglob("*"):
if f.is_file():
f.unlink()
counts = await restore(path)
assert counts["files"] == 1 and counts["notes"] == 1
async with session_scope() as fresh:
back = await fresh.get(Note, uuid.UUID(note["id"]))
assert back.body == "buy milk #grocery"
tags = (await fresh.execute(select(NoteLabel).where(NoteLabel.note_id == back.id))).scalars().all()
assert len(tags) == 1
att = await fresh.scalar(select(NoteAttachment).where(NoteAttachment.note_id == back.id))
assert (Config.media_root() / att.path).read_bytes() == b"photo bytes"
# And it refuses an instance that already has accounts.
with pytest.raises(SystemExit):
await restore(path)
await _backup_settings_off()
async def test_back_up_now_says_what_is_wrong_with_the_destination(app_client, db, tmp_path):
from inkwell import backup_job
await _backup_settings_off()
await _admin_with_invite(app_client)
await app_client.patch("/api/settings", json={"backup_folder": str(tmp_path / "not-mounted")})
tested = await app_client.post("/api/backups/test")
assert tested.status_code == 400
assert "isn't there" in (await tested.get_json())["error"]
assert (await app_client.post("/api/backups/run")).status_code == 202
await backup_job.drain()
status = await (await app_client.get("/api/backups")).get_json()
assert status["runs"][0]["ok"] is False
assert "isn't there" in status["runs"][0]["error"]
await _backup_settings_off()