feat(523): restore reads the backup tar; python -m scribe.restore brings a fresh instance back
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Failing after 1m8s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Failing after 1m8s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Skipped
The restore route takes the tar as a raw body beside the JSON download. scribe/restore.py reads either format, refuses a database that already has an account (a restore re-creates everything with new ids, so into a live instance it would duplicate rather than replace), has no size limit, and says to restart so search indexes rebuild. An integration test restores a written tar through the real restore_full_backup and checks every id is remapped. docs/backups.md explains the file, why it is uncompressed, both destinations, Fabled Archives as the worked example, and restoring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+158
@@ -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 `<table>.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://<archives>: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.
|
||||||
+6
-2
@@ -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
|
- **Personal export** — download all your notes/tasks as a Markdown ZIP (YAML
|
||||||
frontmatter) or a JSON array.
|
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.
|
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
|
## 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 |
|
| Account | Email change, password change, session invalidation |
|
||||||
| Profile | Per-user profile fields |
|
| Profile | Per-user profile fields |
|
||||||
| Integrations | SearXNG status |
|
| 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 |
|
| API Keys | Create/revoke `fmcp_` keys for the MCP endpoint |
|
||||||
| Config (admin) | Base URL, SMTP, DB-maintenance schedule |
|
| Config (admin) | Base URL, SMTP, DB-maintenance schedule |
|
||||||
| Users (admin) | User list, invite links, registration toggle |
|
| Users (admin) | User list, invite links, registration toggle |
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -59,7 +59,21 @@ async def backup():
|
|||||||
@admin_bp.route("/restore", methods=["POST"])
|
@admin_bp.route("/restore", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
async def restore():
|
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:
|
if not data:
|
||||||
return jsonify({"error": "No backup data provided"}), 400
|
return jsonify({"error": "No backup data provided"}), 400
|
||||||
|
|
||||||
|
|||||||
@@ -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 == []
|
||||||
Reference in New Issue
Block a user