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
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user