diff --git a/docs/backups.md b/docs/backups.md new file mode 100644 index 00000000..2adc52c9 --- /dev/null +++ b/docs/backups.md @@ -0,0 +1,158 @@ +# Backups + +Scribe can back itself up every day to a **folder** or an **S3-compatible +bucket**. Each run adds one file; Scribe never deletes an old one. Keeping and +thinning the history is the destination's job. + +Set it up under **Settings → Data → Scheduled backups** (admins only). + +## What a backup is + +One file per run, named for the moment it was taken in UTC: + +``` +scribe-20261015T020000Z.tar +``` + +It is a plain, **uncompressed** tar: + +- `manifest.json` comes first. It holds the backup format version, the Scribe + version, when the backup was taken, and how many rows each table has. +- Then there is one `.jsonl` per table, one row per line. + +It holds everything the admin **Full Backup** download holds, laid out +differently. It does **not** hold: + +- **Search indexes.** They are rebuilt from the records when Scribe starts. +- **Sharing:** groups, project shares and note shares. +- **Credentials:** API keys and forge connections. After a restore, issue new + API keys and add forge connections again. +- **Logs and notifications.** +- **The backup destination's own S3 secret.** + +The file contains hashed passwords, so store it somewhere only trusted people +can read. + +### Why it is shaped this way + +The file is meant to be copied off-site by a deduplicating tool such as +restic, which stores a chunk of data only once no matter how many backups +contain it. Every choice serves that: + +- **It is never compressed.** Compression changes every byte after the first + edit, so nothing would match the day before. restic compresses its own + repository after deduplicating, so nothing is lost. +- **Rows are in primary-key order, with their keys sorted.** A row that did + not change is the same bytes in the same place every day. An edited row + changes one line, and new rows land at the end of their table's file. +- **Each table is its own file**, so a busy table never shifts the bytes of a + quiet one. +- **The parts that change every run are kept in `manifest.json`**: the time + and the counts. +- **Each run gets a new, dated name.** Overwriting one name would keep one + copy and no history. + +Day to day, the off-site copy then stores roughly what changed, not the whole +file again. + +## Destinations + +### A folder + +A path **inside the Scribe container**, normally a share mounted there: + +```yaml +services: + scribe: + volumes: + - /nfs/backup/scribe:/backups +``` + +Scribe writes each file under a hidden name (`.scribe-….tar.part`) and renames +it once it is complete. A program watching the folder never sees half a +backup. + +### An S3-compatible bucket + +You need four things: + +- **Address:** the service itself, like `https://backup.example.com`. Don't + include the bucket; Scribe addresses buckets by path. +- **Bucket.** +- **Access key.** +- **Secret key.** + +Scribe uploads and lists; it never deletes. A key that may **only add +objects** is therefore enough, and it is the safer key to hand out: a stolen +one cannot wipe the history. + +The secret is stored encrypted, under a key derived from `SECRET_KEY`. It is +never shown again, and never included in a backup. If an instance's +`SECRET_KEY` changes, for example after a restore onto a new instance, the +page asks for the secret again. + +**Test destination** checks the destination without sending a backup: + +- a folder: it writes and removes a hidden file; +- a bucket: it only lists, because an add-only key could never remove a test + file. + +## Schedule and history + +- Backups run once a day at the hour you choose, in UTC. The default is 02:00, + before the nightly trash purge and database maintenance. +- **Back up now** runs one straight away. +- The page leads with when the last good backup was taken. It says + **Overdue** when backups are on and none has worked for 26 hours. +- It lists the last 30 runs, and gives the reason for each failure in words. +- A run that hasn't finished after an hour is abandoned and recorded as + failed. + +## Example: Fabled Archives + +[Fabled Archives](https://git.fabledsword.com/bvandeusen/fabledarchives) +keeps service backups as "backup targets". Either destination works with it. + +1. On Archives' **Backups** page, make a target for Scribe, named `scribe` + for example. +2. Choose how Scribe's files arrive: + - **S3**: turn on the S3 way in for the target and make an **append-only** + S3 key. In Scribe, choose **An S3 bucket**: + - address: Archives' backup address (`http://:8081`); + - bucket: the target's name; + - access key and secret key: the key you just made. + - **A folder**: mount the share Archives watches into Scribe as + `/backups/scribe`, and point the target at that folder. In Scribe, choose + **A folder** with `/backups/scribe`. +3. Set the target's **Retention** to **Archives**. Archives then keeps the + newest backups and thins the rest by day, week and month. Scribe only adds. +4. Give the target an expected cadence of **daily**, so a missed backup turns + it late in Archives as well as overdue in Scribe. +5. Add an off-site **restic** replication job for the target. The uncompressed + layout above is what lets that job send only what changed each day. + +## Restoring + +### Into a fresh instance + +Start Scribe on an empty database, copy the backup file into the container, +and run: + +```sh +docker exec -it scribe python -m scribe.restore /backups/scribe-20261015T020000Z.tar +``` + +- The command refuses a database that already has an account. A restore + re-creates everything with new ids, so on a live instance it would add a + second copy instead of replacing anything. +- Nothing limits the file's size. +- It also reads the JSON from the admin **Full Backup** download. + +Afterwards, **restart Scribe** so the search indexes are rebuilt. Then issue +new API keys, add forge connections again, and set up any sharing. + +### From the web + +**Settings → Data → Restore from Backup** takes the `.tar` as well as the +JSON download. The upload is held to the server's request-size limit. For a +bigger file, use the command above. diff --git a/docs/features.md b/docs/features.md index 892ede30..0ef84740 100644 --- a/docs/features.md +++ b/docs/features.md @@ -127,8 +127,12 @@ Every read and mutation is scoped by owner + direct shares + group shares. - **Personal export** — download all your notes/tasks as a Markdown ZIP (YAML frontmatter) or a JSON array. -- **Admin backup** — full application backup/restore (format v4) with ID remapping on +- **Admin backup** — full application backup/restore with ID remapping on restore for cross-instance migration. +- **Scheduled backups** — a daily, dated, uncompressed `.tar` to a folder or an + S3-compatible bucket, laid out so a deduplicating tool such as restic stores + only what changed; `python -m scribe.restore` brings a fresh instance back + from one. See [Backups](backups.md). ## Progressive Web App @@ -148,7 +152,7 @@ reset, and session invalidation are included. See [SSO / OAuth](sso-oauth.md). | Account | Email change, password change, session invalidation | | Profile | Per-user profile fields | | Integrations | SearXNG status | -| Data | Personal export; backup / restore (admin) | +| Data | Personal export; backup / restore and scheduled backups (admin) | | API Keys | Create/revoke `fmcp_` keys for the MCP endpoint | | Config (admin) | Base URL, SMTP, DB-maintenance schedule | | Users (admin) | User list, invite links, registration toggle | diff --git a/src/scribe/restore.py b/src/scribe/restore.py new file mode 100644 index 00000000..24edcfd3 --- /dev/null +++ b/src/scribe/restore.py @@ -0,0 +1,95 @@ +"""Restore a Scribe backup into an empty instance, from the command line. + + python -m scribe.restore /backups/scribe-20261015T020000Z.tar + +This is how a fresh instance is brought back after losing the old one. Run it +inside the Scribe container, which already has the database settings and has +already migrated the schema on start. It reads either the scheduled backup's +tar (services/backup_file.py) or the JSON the admin download produces. + +It refuses a database that already holds an account. A restore re-creates +every user, project and record from the file with new ids; into a live +instance it would add a second copy of everything rather than replace it. +(Settings → Backup & Restore still offers the upload for that case, on +purpose and with its own warning.) + +Unlike the upload, nothing limits the file's size here, so this is also the +way in for a backup too big for the web upload. + +Afterwards, restart Scribe: search indexes are not part of a backup and are +rebuilt from the restored records at startup. +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path + +from sqlalchemy import func, select + + +def load_backup(path: Path) -> dict: + """The payload in `path`, whichever of the two backup formats it is.""" + from scribe.services.backup_file import is_archive, read_archive + + with open(path, "rb") as f: + if is_archive(f): + return read_archive(f) + return json.load(f) + + +async def _account_count() -> int: + from scribe.models import async_session + from scribe.models.user import User + + async with async_session() as session: + return (await session.execute(select(func.count()).select_from(User))).scalar_one() + + +async def run(path: Path) -> int: + from scribe.models.engine import engine + from scribe.services.backup import restore_full_backup + from scribe.services.backup_file import ArchiveError + + try: + try: + payload = load_backup(path) + except FileNotFoundError: + print(f"There is no file at {path}.", file=sys.stderr) + return 2 + except (ArchiveError, ValueError) as exc: + print(f"{path} isn't a backup this Scribe can read: {exc}", file=sys.stderr) + return 2 + + accounts = await _account_count() + if accounts: + print( + f"This database already holds {accounts} account(s). A restore adds a second " + "copy of everything rather than replacing it, so it only runs into an empty " + "instance. Start Scribe on an empty database and run this again.", + file=sys.stderr, + ) + return 3 + + stats = await restore_full_backup(payload) + print(json.dumps({"restored": stats}, indent=2, default=str)) + print("Restored. Restart Scribe so the search indexes are rebuilt.", file=sys.stderr) + return 0 + finally: + await engine.dispose() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="python -m scribe.restore", + description="Restore a Scribe backup (.tar or .json) into an empty instance.", + ) + parser.add_argument("file", type=Path, help="the backup file") + args = parser.parse_args(argv) + return asyncio.run(run(args.file)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/scribe/routes/admin.py b/src/scribe/routes/admin.py index 611754cb..43e98641 100644 --- a/src/scribe/routes/admin.py +++ b/src/scribe/routes/admin.py @@ -59,7 +59,21 @@ async def backup(): @admin_bp.route("/restore", methods=["POST"]) @admin_required async def restore(): - data = await request.get_json() + """Restore from an uploaded backup: the JSON download (application/json) + or the scheduled backup's tar (sent as the raw body). The upload is held + to the app's request-size limit; `python -m scribe.restore` has none, and + is the way in for a bigger file or a fresh instance.""" + if request.mimetype == "application/json": + data = await request.get_json() + else: + import io + + from scribe.services.backup_file import ArchiveError, read_archive + + try: + data = read_archive(io.BytesIO(await request.get_data())) + except ArchiveError as exc: + return jsonify({"error": f"That file isn't a Scribe backup: {exc}"}), 400 if not data: return jsonify({"error": "No backup data provided"}), 400 diff --git a/tests/test_integration_backup_file_restore.py b/tests/test_integration_backup_file_restore.py new file mode 100644 index 00000000..913ba7ac --- /dev/null +++ b/tests/test_integration_backup_file_restore.py @@ -0,0 +1,103 @@ +"""Real-Postgres proof that the scheduled backup's FILE restores (milestone +523): a payload written as the tar, read back through `scribe.restore`'s +loader, and fed to the real `restore_full_backup`. + +The unit lane already shows the tar hands back the dict it was given. What +only this lane shows is that the dict it hands back is one the restore +accepts — every id remapped to the restored rows, nothing dropped — and that +the command-line restore refuses a database that already has accounts. +""" +import pytest +import pytest_asyncio +from sqlalchemy import select + +from scribe.models import async_session +from scribe.models.milestone import Milestone +from scribe.models.note import Note +from scribe.models.project import Project +from scribe.models.user import User +from scribe.restore import load_backup, run +from scribe.services import backup +from scribe.services.backup_file import write_archive +from tests.helpers import ensure_user + +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] + +OWNER = "backup_file_restore_owner" +RESTORED = "backup_file_restore_restored" + + +async def _purge(username: str) -> None: + async with async_session() as s: + for user in (await s.execute(select(User).where(User.username == username))).scalars().all(): + for model in (Note, Milestone, Project): + for row in (await s.execute( + select(model).where(model.user_id == user.id) + )).scalars().all(): + await s.delete(row) + await s.flush() + await s.delete(user) + await s.commit() + + +@pytest_asyncio.fixture(autouse=True) +async def _no_leftovers(): + """Setup only — see the rule_version roundtrip module for why.""" + await _purge(RESTORED) + await _purge(OWNER) + + +@pytest_asyncio.fixture +async def written(tmp_path): + """A project with a milestone and a task in it, exported with the real + row builders, renamed so the restore mints a separate user, and written + as the backup tar.""" + async with async_session() as s: + user = await ensure_user(s, OWNER) + project = Project(user_id=user.id, title="Restore drill") + s.add(project) + await s.flush() + milestone = Milestone(user_id=user.id, project_id=project.id, title="Plan") + s.add(milestone) + await s.flush() + note = Note(user_id=user.id, project_id=project.id, milestone_id=milestone.id, + title="Prove the restore", body="From the tar, not the JSON.", + status="todo", note_type="note", task_kind="work") + s.add(note) + await s.commit() + payload = { + "version": backup.BACKUP_VERSION, + "scope": "full", + "users": backup._user_rows([user]), + "projects": backup._project_rows([project]), + "milestones": backup._milestone_rows([milestone]), + "notes": backup._note_rows([note]), + } + payload["users"][0]["username"] = RESTORED + from datetime import datetime, timezone + path = write_archive(payload, tmp_path, datetime.now(timezone.utc), app_version="test") + yield path + await _purge(RESTORED) + await _purge(OWNER) + + +async def test_the_tar_restores_with_every_id_remapped(written): + await backup.restore_full_backup(load_backup(written)) + async with async_session() as s: + user = (await s.execute(select(User).where(User.username == RESTORED))).scalars().one() + project = (await s.execute(select(Project).where(Project.user_id == user.id))).scalars().one() + milestone = (await s.execute(select(Milestone).where(Milestone.user_id == user.id))).scalars().one() + note = (await s.execute(select(Note).where(Note.user_id == user.id))).scalars().one() + assert project.title == "Restore drill" + assert (note.project_id, note.milestone_id) == (project.id, milestone.id) + assert note.body == "From the tar, not the JSON." + assert note.status == "todo" + + +async def test_the_command_refuses_a_database_with_accounts(written): + """The source user exists, so this database is not empty: the command + must stop before restoring anything.""" + assert await run(written) == 3 + async with async_session() as s: + restored = (await s.execute(select(User).where(User.username == RESTORED))).scalars().all() + assert restored == []