diff --git a/docker-compose.yml b/docker-compose.yml index 1a2959c..9fa22dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,13 +14,10 @@ # Every push publishes an immutable : image for exactly this. # # Schema migrations run automatically at container start (see the Dockerfile CMD), -# so an upgrade is just a pull and a restart. Take a backup first anyway: -# -# docker compose exec -T db pg_dump -U inkwell inkwell > backup.sql -# -# Attachments are files, not rows — they live in the `inkwell-data` volume and -# a pg_dump does NOT contain them. Back up both or you'll restore notes whose images -# are gone. +# so an upgrade is just a pull and a restart. Take a backup first anyway: Settings → +# Backups → Back up now writes one file holding the notes AND their attachments +# (docs/backups.md). A bare pg_dump does not: attachments are files in the +# `inkwell-data` volume, not rows. services: db: @@ -70,6 +67,9 @@ services: # Uploaded attachments. /var/inkwell is fixed in the app (Config.DATA_DIR), # not configurable — mount it or lose every image on container recreation. - inkwell-data:/var/inkwell + # Daily backups to a folder (Settings → Backups, docs/backups.md): mount the + # share they should land in. Not needed when they go to an S3 bucket. + # - /mnt/backups/inkwell:/backups # WHERE THE APP IS REACHABLE FROM. Three shapes, and the right answer is # different for each — the default serves the first. # diff --git a/docs/backups.md b/docs/backups.md new file mode 100644 index 0000000..6e072da --- /dev/null +++ b/docs/backups.md @@ -0,0 +1,125 @@ +# Backups + +Inkwell can write a backup of everything once a day: every account, note, tag, share, +group, invite and setting, and every attachment file. Each backup goes to a folder or +to an S3-compatible bucket, and is shaped so that a deduplicating off-site copy +(restic) stores only what changed. + +Set it up in **Settings → Backups**. + +## What a backup is + +One file per run, named for the moment it was taken: `inkwell-20261016T020000Z.tar`. +It is an ordinary, **uncompressed** tar: + +``` +manifest.json when, which version, how many rows and files +tables/.jsonl one row per line, in primary-key order +files/ every attachment, byte for byte +``` + +The layout is chosen for restic. restic splits a file into chunks at points chosen by +its content, so a chunk it has already stored costs nothing to store again. To make +the most of that: + +- **Nothing is compressed.** Compression changes every byte after the first change, + and nothing would match from one day to the next. restic compresses its own + repository after it deduplicates. +- **An unchanged row is the same bytes every day.** Rows are written in primary-key + order with sorted keys. Editing a note changes one line of `tables/notes.jsonl`. +- **Attachments never change**, so they deduplicate run after run. +- **Only `manifest.json` changes every run.** It holds the time and the counts; the + tar headers are fixed. + +In practice a day's new backup adds roughly the size of that day's edits and uploads +to the off-site repository, not the size of the whole file. + +A backup **never** holds a credential: not the SMTP password, not the bucket's secret +key, and not the server's own signing key. A copy kept off-site cannot be used to +reach the server or the bucket it came from. One-hour password reset links are left +out too. + +## Where it goes + +Inkwell only ever **adds** a file. It never deletes or replaces one. Keeping a +history and thinning it (keep 7 daily, 4 weekly, 12 monthly, say) is the +destination's job. + +### A folder + +Mount a share into the container and give its path in Settings: + +```yaml +services: + app: + volumes: + - inkwell-data:/var/inkwell + - /mnt/archives/inkwell:/backups # add this line +``` + +The file is written as `.inkwell-….tar.part` and renamed when it is complete, so a +service that watches the folder and skips names starting with a dot never picks up +half a backup. + +### An S3-compatible bucket + +Any S3 server that accepts path-style addresses: Fabled Archives, MinIO, Garage, +Backblaze B2 and AWS itself. Give the address **with its port**, the bucket, the +region (`us-east-1` suits most self-hosted servers) and a key. + +Inkwell only puts and lists, so a key that may **only add objects** is enough. It is +also the safer key: a stolen copy cannot delete the history it writes into. +**Test destination** only lists the bucket, so it works with such a key. + +Uploads are capped at 10 MB/s by default (**Upload limit**), so a large backup never +crowds the network. Set it to 0 for no limit. + +### Fabled Archives + +This is the worked example. Make a backup target in Archives with retention set to +**Archives** (one file per backup, thinned like restic's keep-daily/weekly/monthly) +and either: + +- **S3:** set the destination to the bucket, using Archives' S3 address including the + port (for example `http://192.168.0.10:8081`). Without the port the request lands on + port 80, and the answer is a plain 404, shown as "nothing answered as S3 at that + address; check the port". Give Inkwell an append-only key. +- **Folder:** mount the target's folder into the container (above) and point the + folder setting at it. + +Archives then copies the target off-site with restic, and the shape above is what +lets that copy deduplicate. + +## When it runs + +Once a day at the hour you choose (UTC on the server; the page shows the next run in +your own time). A backup missed while the server was down runs as soon as it is back. +Switching backups on runs the first one within a few minutes, which is also the +quickest check that the destination works. + +A failed run is tried again after an hour, then two, then four, and then waits for +the next day. The page says when backups started failing and why. Every run has a +one-hour deadline, and the page keeps the last 30. + +**Back up now** makes one straight away. + +## Restoring + +Restore into a **new, empty** Inkwell: + +1. Start the new instance once, so its database is created. Don't sign up. +2. Put the backup file where the container can read it, then run: + + ```sh + docker compose exec app python -m inkwell.restore /backups/inkwell-20261016T020000Z.tar + ``` + +3. Sign in with an account from the backup. +4. In Settings, enter the SMTP password and the bucket's secret key again. They are + never in a backup. + +The restore refuses a database that already has accounts, because loading one +instance's notes over another's would mix the two with no way to separate them. + +Everyone signs in again after a restore, because the signing key is not in the +backup. Linked desktop and Android apps keep their links, and should be re-synced. diff --git a/frontend/src/components/BackupPanel.vue b/frontend/src/components/BackupPanel.vue new file mode 100644 index 0000000..0a9b01b --- /dev/null +++ b/frontend/src/components/BackupPanel.vue @@ -0,0 +1,234 @@ + + + diff --git a/frontend/src/notes/datetime.ts b/frontend/src/notes/datetime.ts index d828d84..804e2a6 100644 --- a/frontend/src/notes/datetime.ts +++ b/frontend/src/notes/datetime.ts @@ -1,3 +1,5 @@ +import { counted } from "../counted"; + // Reminder datetime helpers. Reminders are stored as ISO UTC; the editor's // works in local time. @@ -82,3 +84,29 @@ export function formatTrashCountdown(daysLeft: number | null): string { if (daysLeft === 1) return "1 day left"; return `${daysLeft} days left`; } + +// How long ago a moment was, roughly: "4 minutes ago", "3 hours ago", "2 days ago". +// For a status line, where the size of the gap matters more than the exact time. +export function timeAgo(iso: string | null, now: number = Date.now()): string { + if (!iso) return ""; + const minutes = Math.max(0, Math.round((now - new Date(iso).getTime()) / 60000)); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${counted(minutes, "minute", "minutes")} ago`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${counted(hours, "hour", "hours")} ago`; + return `${counted(Math.round(hours / 24), "day", "days")} ago`; +} + +// A moment exactly, in the reader's zone on a 24-hour clock, with its date so a row +// still reads right after the day turns over: "Fri 17 Oct 04:00". +export function formatExact(iso: string | null): string { + if (!iso) return ""; + return new Date(iso).toLocaleString(undefined, { + weekday: "short", + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }); +} diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index e41a2b8..e3e1b5d 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -11,6 +11,7 @@ import InviteList from "../components/InviteList.vue"; import AccountList from "../components/AccountList.vue"; import ActivityList from "../components/ActivityList.vue"; import GroupList from "../components/GroupList.vue"; +import BackupPanel from "../components/BackupPanel.vue"; import { errorMessage } from "../api/errors"; import { useUiStore } from "../stores/ui"; @@ -35,6 +36,7 @@ const TABS: Tab[] = [ { id: "people", label: "People", groups: ["Access"] }, { id: "email", label: "Email", groups: ["Email"] }, { id: "security", label: "Security", groups: ["Security"] }, + { id: "backups", label: "Backups", groups: ["Backups"] }, { id: "activity", label: "Activity", groups: [] }, ]; const DEFAULT_TAB = "general"; @@ -61,6 +63,8 @@ const loadError = ref(""); const saving = ref(false); const error = ref(""); const saved = ref(false); +// Bumped on every save, so the backup status above its fields follows them. +const savedCount = ref(0); const isDirty = (it: ServerSetting) => it.value !== original.value[it.key]; const tabItems = computed(() => items.value.filter((it) => tabOf(it.group) === tab.value)); @@ -77,10 +81,20 @@ watch(tab, () => { error.value = ""; }); +/** A field that means nothing for the choice beside it: the bucket's fields while + * backups go to a folder, and the folder while they go to a bucket. */ +function irrelevant(it: ServerSetting): boolean { + const dest = items.value.find((i) => i.key === "backup_destination")?.value; + if (it.key === "backup_folder") return dest === "s3"; + if (it.key.startsWith("backup_s3_") || it.key === "backup_upload_limit_kbps") return dest !== "s3"; + return false; +} + // The current tab's fields, under their group's heading, in registry order. const groups = computed(() => { const map = new Map(); for (const it of tabItems.value) { + if (irrelevant(it)) continue; const arr = map.get(it.group) ?? []; arr.push(it); map.set(it.group, arr); @@ -119,6 +133,7 @@ async function save() { snapshot(fresh); items.value = fresh.map((it) => (pending.has(it.key) ? { ...it, value: pending.get(it.key) as SettingValue } : it)); saved.value = true; + savedCount.value++; await config.reload(); // header/site name reflects changes immediately } catch (e) { error.value = errorMessage(e, "Could not save settings."); @@ -226,6 +241,9 @@ onMounted(load);

+ + +
diff --git a/src/inkwell/app.py b/src/inkwell/app.py index c3ff79f..393020b 100644 --- a/src/inkwell/app.py +++ b/src/inkwell/app.py @@ -25,6 +25,8 @@ from .notes import bp as notes_bp from .proxy import is_https from .responses import json_error, not_found from .retention import run_sweeper +from .backup_api import bp as backups_bp +from .backup_job import run_scheduler as run_backup_scheduler from .settings import MAX_BODY_MB, apply_session_ttl, get_public_config, get_setting, load_or_create_secret_key, refresh_live from .settings_api import bp as settings_bp from .shares_api import bp as shares_bp @@ -106,6 +108,7 @@ def create_app() -> Quart: app.register_blueprint(sync_bp) app.register_blueprint(client_bp) app.register_blueprint(installer_bp) + app.register_blueprint(backups_bp) @app.before_serving async def _bootstrap() -> None: @@ -128,15 +131,19 @@ def create_app() -> Quart: # sweep. Duplicate sweeps would be harmless but wasteful — a purged row is # skipped by `purged_at IS NULL` — so this is about load, not correctness. app.config["TRASH_SWEEPER"] = asyncio.create_task(run_sweeper()) + # The daily backup (backup_job.py), on the same single-worker footing. It also + # takes a Postgres advisory lock, so a second process could not double it. + app.config["BACKUP_SCHEDULER"] = asyncio.create_task(run_backup_scheduler(app.config["APP_VERSION"])) @app.after_serving async def _shutdown() -> None: - task = app.config.get("TRASH_SWEEPER") - if task is not None: - task.cancel() - # Await the cancellation so shutdown doesn't race a sweep mid-transaction. - with suppress(asyncio.CancelledError): - await task + for name in ("TRASH_SWEEPER", "BACKUP_SCHEDULER"): + task = app.config.get(name) + if task is not None: + task.cancel() + # Await the cancellation so shutdown doesn't race a sweep mid-transaction. + with suppress(asyncio.CancelledError): + await task @app.after_request async def _security_headers(response): diff --git a/src/inkwell/backup_api.py b/src/inkwell/backup_api.py new file mode 100644 index 0000000..12dec84 --- /dev/null +++ b/src/inkwell/backup_api.py @@ -0,0 +1,39 @@ +"""Admin routes for Settings → Backups (#5920): where backups stand, Back up now, and +Test destination. What a backup holds and when it runs is in `backup_job`.""" +from __future__ import annotations + +from quart import Blueprint, current_app, jsonify + +from . import backup_job +from .auth import require_admin +from .backup_destination import DestinationError +from .responses import json_error + +bp = Blueprint("backups", __name__, url_prefix="/api/backups") + + +@bp.get("") +@require_admin +async def backup_status(): + return jsonify(await backup_job.status()) + + +@bp.post("/run") +@require_admin +async def back_up_now(): + """Starts a backup in the background and answers at once: a backup with many + attachments takes longer than a request should wait. The page polls the status.""" + try: + backup_job.start_now(app_version=current_app.config["APP_VERSION"]) + except backup_job.AlreadyRunning: + return json_error("A backup is already running.", 409) + return jsonify({"started": True}), 202 + + +@bp.post("/test") +@require_admin +async def check_backup_destination(): + try: + return jsonify({"ok": True, "message": await backup_job.check_destination()}) + except DestinationError as exc: + return json_error(str(exc), 400) diff --git a/src/inkwell/backup_data.py b/src/inkwell/backup_data.py new file mode 100644 index 0000000..0b26a19 --- /dev/null +++ b/src/inkwell/backup_data.py @@ -0,0 +1,169 @@ +"""What goes into a backup, and putting it back (#5918, #5921). + +Every table the models define is exported, so a table added later is backed up +without anyone remembering to add it here. The few that are left out are named in +`NOT_INCLUDED` with the reason, and a test fails if a table is neither exported nor +named there (tests/test_backup.py). + +Values are written as JSON: uuids and datetimes as strings, everything else as it +is. On the way back each column's type decides how its string is read. +""" +from __future__ import annotations + +import logging +import os +import uuid +from datetime import datetime +from pathlib import Path +from typing import IO, Iterable + +from sqlalchemy import DateTime, func, insert, select +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from .backup_file import Contents, Member, read_rows +from .config import Config +from .models import Base +from .models import all as _all_models # noqa: F401 (registers every table) +from .models.note_attachment import NoteAttachment +from .models.settings import Setting +from .models.user import User +from .settings import REGISTRY, SECRET_KEY_SETTING + +logger = logging.getLogger(__name__) + +# Tables a backup leaves out, and why. A restored instance has them empty. +NOT_INCLUDED: dict[str, str] = { + "password_resets": "reset links last an hour; any in a backup would be expired by the time it is restored", +} + +# Settings rows a backup leaves out. Credentials never leave the server: a backup is +# copied off-site, and a copy should not carry the keys that open this server or the +# places it sends to. The run history describes this instance, not the restored one. +BACKUP_RUNS_SETTING = "backup_runs" +SETTINGS_NOT_INCLUDED = frozenset( + {SECRET_KEY_SETTING, BACKUP_RUNS_SETTING} | {d.key for d in REGISTRY if d.secret} +) + +# Rows per INSERT on restore. Big enough to be quick, small enough that one bad row +# names a batch a person can look through. +RESTORE_BATCH = 500 + + +def exported_tables(): + """The tables a backup holds, parents before children (the restore order).""" + return [t for t in Base.metadata.sorted_tables if t.name not in NOT_INCLUDED] + + +def _encode(value): + if isinstance(value, uuid.UUID): + return str(value) + if isinstance(value, datetime): + return value.isoformat() + return value + + +def _decoder(column): + if isinstance(column.type, UUID): + return lambda v: None if v is None else uuid.UUID(v) + if isinstance(column.type, DateTime): + return lambda v: None if v is None else datetime.fromisoformat(v) + return lambda v: v + + +async def export_contents(db) -> Contents: + """Every exported table's rows in primary-key order, and the attachment files. + + Primary-key order is load-bearing: Postgres returns rows in heap order, and an + UPDATE moves a row to the end of the heap, so without ORDER BY an unchanged table + would come out shuffled after any edit and nothing would deduplicate.""" + tables: dict[str, list[dict]] = {} + for table in exported_tables(): + query = select(table).order_by(*table.primary_key.columns) + if table.name == Setting.__tablename__: + query = query.where(table.c.key.not_in(SETTINGS_NOT_INCLUDED)) + result = await db.execute(query) + tables[table.name] = [{k: _encode(v) for k, v in row.items()} for row in result.mappings()] + + media = Config.media_root() + files: dict[str, Path] = {} + missing: list[str] = [] + for row in tables.get(NoteAttachment.__tablename__, []): + rel = row["path"] + path = media / rel + if path.is_file(): + files[rel] = path + else: + missing.append(rel) + return Contents(tables=tables, files=files, missing=missing, not_included=dict(NOT_INCLUDED)) + + +# ---- restore ------------------------------------------------------------------------- + + +class RestoreError(Exception): + """Why a restore was refused, said to the person running it.""" + + +async def instance_is_empty(db) -> bool: + return (await db.scalar(select(func.count()).select_from(User))) == 0 + + +async def restore_tables(db, members: Iterable[Member]) -> dict[str, int]: + """Load a backup's members into an EMPTY database. Returns rows per table. + + Tables come out of the archive by name, and are inserted parents first. Files + are written into the media folder as they arrive (they come after every table). + The caller commits.""" + by_name = {t.name: t for t in exported_tables()} + pending: dict[str, list[dict]] = {} + counts: dict[str, int] = {} + tables_loaded = False + + async def load_tables() -> None: + for table in exported_tables(): + rows = pending.get(table.name, []) + if not rows: + continue + decoders = {c.name: _decoder(c) for c in table.columns} + typed = [{k: decoders[k](v) for k, v in row.items() if k in decoders} for row in rows] + for start in range(0, len(typed), RESTORE_BATCH): + batch = typed[start : start + RESTORE_BATCH] + if table.name == Setting.__tablename__: + # A started server has already written its own settings (its + # signing key, at least). The backup's win for everything else. + stmt = pg_insert(table).values(batch) + stmt = stmt.on_conflict_do_update(index_elements=["key"], set_={"value": stmt.excluded.value}) + await db.execute(stmt) + else: + await db.execute(insert(table), batch) + counts[table.name] = len(rows) + + media = Config.media_root() + for member in members: + if member.kind == "table": + if member.name not in by_name: + # A table this version no longer has. Nothing to put it in. + logger.warning("restore: skipping table %s, which this version doesn't have", member.name) + member.fileobj.read() + continue + pending[member.name] = read_rows(member) + else: + if not tables_loaded: + await load_tables() + tables_loaded = True + _write_media(media, member.name, member.fileobj) + counts["files"] = counts.get("files", 0) + 1 + if not tables_loaded: + await load_tables() + return counts + + +def _write_media(media: Path, rel: str, src: IO[bytes]) -> None: + dest = media / rel + dest.parent.mkdir(parents=True, exist_ok=True) + part = dest.with_name(f".{dest.name}.part") + with open(part, "wb") as out: + while chunk := src.read(1 << 20): + out.write(chunk) + os.replace(part, dest) diff --git a/src/inkwell/backup_destination.py b/src/inkwell/backup_destination.py new file mode 100644 index 0000000..dc3e126 --- /dev/null +++ b/src/inkwell/backup_destination.py @@ -0,0 +1,175 @@ +"""Where the daily backup goes, and getting a backup file there (#5919). + +Two kinds of destination, both set in Settings → Backups: + +- **folder**: a directory inside the container, usually a mounted share. A backup + service that watches the share (Fabled Archives' folder mode, say) or a person + takes the files from there. +- **s3**: any S3-compatible bucket, addressed by path. Inkwell only puts and lists, + so a key that may only add objects is enough. + +Either way Inkwell ADDS one dated file per run and never removes one. Keeping and +thinning the history is the destination's job; backup_file.py says why the file is +shaped the way it is for that. +""" +from __future__ import annotations + +import asyncio +import shutil +import tempfile +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from . import backup_file +from .backup_s3 import S3Error, S3Target, list_objects, put_file +from .config import Config +from .settings import get_setting + + +class DestinationError(Exception): + """Why the destination can't take a backup, said to the person.""" + + +@dataclass(frozen=True) +class Destination: + kind: str # "folder" | "s3" + folder: str + endpoint: str + bucket: str + region: str + access_key: str + secret_key: str + limit_kbps: int + + def problems(self) -> list[str]: + """What stops this destination taking a backup, in words. Empty when it is + complete, which says nothing about whether it answers: `check` does that.""" + out: list[str] = [] + if self.kind == "folder": + if not self.folder.startswith("/"): + out.append("The folder must be an absolute path inside the container.") + return out + if not self.endpoint: + out.append("The S3 address is missing.") + if not self.bucket: + out.append("The bucket name is missing.") + if not self.access_key: + out.append("The access key is missing.") + if not self.secret_key: + out.append("The secret key is missing.") + return out + + def where(self) -> str: + if self.kind == "folder": + return self.folder + return f"bucket {self.bucket} at {self.endpoint}" if self.bucket else "an S3 bucket" + + def target(self) -> S3Target: + return S3Target(self.endpoint, self.bucket, self.access_key, self.secret_key, self.region or "us-east-1") + + +async def load_destination(db) -> Destination: + return Destination( + kind=await get_setting(db, "backup_destination"), + folder=(await get_setting(db, "backup_folder")).strip(), + endpoint=await get_setting(db, "backup_s3_endpoint"), + bucket=(await get_setting(db, "backup_s3_bucket")).strip(), + region=(await get_setting(db, "backup_s3_region")).strip(), + access_key=(await get_setting(db, "backup_s3_access_key")).strip(), + secret_key=await get_setting(db, "backup_s3_secret_key"), + limit_kbps=await get_setting(db, "backup_upload_limit_kbps"), + ) + + +@dataclass(frozen=True) +class Delivered: + name: str + size_bytes: int + where: str + manifest: dict + + +def _require(dest: Destination) -> None: + problems = dest.problems() + if problems: + raise DestinationError(" ".join(problems)) + + +def _folder(dest: Destination) -> Path: + folder = Path(dest.folder) + if not folder.is_dir(): + raise DestinationError( + f"{folder} isn't there. Mount the backup share at that path in the container, " + "or set the folder it is mounted at." + ) + return folder + + +async def deliver(contents: backup_file.Contents, dest: Destination, at: datetime, *, app_version: str) -> Delivered: + """Write the dated backup file and put it at the destination. + + Written off the event loop: serialising every table and copying every attachment + is real work, and the app keeps serving meanwhile.""" + _require(dest) + if dest.kind == "folder": + folder = _folder(dest) + try: + path, manifest = await asyncio.to_thread( + backup_file.write_archive, contents, folder, at, app_version=app_version + ) + except PermissionError: + raise DestinationError(f"Inkwell isn't allowed to write in {folder}.") from None + return Delivered(path.name, path.stat().st_size, dest.folder, manifest) + + # Staged on the data volume, not /tmp: the volume already holds every attachment, + # so it has room for a copy of them, which a container's /tmp may not. + Config.data_root().mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix=".backup-staging-", dir=Config.data_root())) + try: + path, manifest = await asyncio.to_thread( + backup_file.write_archive, contents, staging, at, app_version=app_version + ) + size = path.stat().st_size + try: + await asyncio.to_thread(put_file, dest.target(), path.name, path, limit_kbps=dest.limit_kbps) + except S3Error as exc: + raise DestinationError(str(exc)) from None + finally: + shutil.rmtree(staging, ignore_errors=True) + return Delivered(path.name, size, dest.where(), manifest) + + +async def check(dest: Destination) -> str: + """Ask the destination whether it would take a backup, without sending one. + Returns what was found, in words; raises DestinationError otherwise. + + The folder check writes and removes a dot-file, which a folder watcher ignores. + The bucket check only lists: an append-only key could add a test object and then + never be able to remove it.""" + _require(dest) + if dest.kind == "folder": + folder = _folder(dest) + probe = folder / ".inkwell-write-check" + try: + await asyncio.to_thread(probe.write_bytes, b"") + await asyncio.to_thread(probe.unlink) + except PermissionError: + raise DestinationError(f"Inkwell isn't allowed to write in {folder}.") from None + except OSError as exc: + raise DestinationError(f"Writing in {folder} failed: {exc.strerror or exc}.") from None + count = sum( + 1 + for p in folder.iterdir() + if p.name.startswith(backup_file.NAME_PREFIX) and p.name.endswith(backup_file.NAME_SUFFIX) + ) + return f"{folder} is writable and holds {_backups(count)}." + try: + objects = await asyncio.to_thread(list_objects, dest.target(), backup_file.NAME_PREFIX) + except S3Error as exc: + raise DestinationError(str(exc)) from None + return f"Bucket {dest.bucket} answered and holds {_backups(len(objects))}." + + +def _backups(n: int) -> str: + return f"{n} Inkwell backup" if n == 1 else f"{n} Inkwell backups" diff --git a/src/inkwell/backup_file.py b/src/inkwell/backup_file.py new file mode 100644 index 0000000..07fea9b --- /dev/null +++ b/src/inkwell/backup_file.py @@ -0,0 +1,216 @@ +"""The full backup as one file: an uncompressed tar per run (#5918). + +A scheduled backup lands somewhere that keeps a history of them: a folder a backup +service watches, or an S3 bucket. From there it is usually copied off-site by a +deduplicating tool such as restic. Everything about this layout serves that copy. +restic cuts a file into chunks at boundaries chosen by the content, so a chunk it +has already stored costs nothing to store again. The file is therefore laid out so +that whatever did not change between two runs is the same bytes both times. + +- **Never compressed.** Compression rewrites every byte after the first change, so + no chunk would ever match again. restic compresses its own repository after it + deduplicates. +- **One member per table**, `tables/
.jsonl`, with one row per line. + - Rows arrive in primary-key order and are written with sorted keys and no + spacing. An unchanged row is the same bytes, an edit changes one line, and a + busy table never shifts the bytes of a quiet one. +- **One member per attachment**, `files/`, in path order. An attachment's + bytes never change after upload, so they deduplicate run after run, whatever + moves around them. +- **Whatever changes every run lives only in `manifest.json`**: the export time, + the app version and the counts. Member headers are fixed (mtime 0, owner 0, mode + 0644), and the members always come in the same order: the manifest, the tables + by name, then the files by path. +- **Each run gets a new dated name**, so a service that keeps one file per backup + can thin the history on its own schedule. +- **Written atomically** through a dot-named `.part` file, which a folder watcher + ignores until the rename. + +The shape is Scribe's (snippet #5875), with attachment files added: in a notes app +the files are as much the user's data as the rows. +""" +from __future__ import annotations + +import io +import json +import os +import tarfile +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import IO, Iterator + +ARCHIVE_FORMAT = "inkwell-backup-tar" +ARCHIVE_FORMAT_VERSION = 1 +MANIFEST_NAME = "manifest.json" +TABLES_DIR = "tables/" +FILES_DIR = "files/" +TABLE_SUFFIX = ".jsonl" +NAME_PREFIX = "inkwell-" +NAME_SUFFIX = ".tar" + + +def archive_name(at: datetime) -> str: + """`inkwell-20261016T020000Z.tar`: UTC, sorts by time, one per run.""" + return f"{NAME_PREFIX}{at.astimezone(timezone.utc):%Y%m%dT%H%M%SZ}{NAME_SUFFIX}" + + +def encode_row(row: dict) -> bytes: + return json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + b"\n" + + +@dataclass +class Contents: + """What one backup holds: rows by table, and the attachment files to copy in. + + `files` maps a path relative to the media root to where it is on disk. A file a + row names but the disk no longer has is listed in `missing`, so the run can say + so instead of failing outright or quietly leaving it out.""" + + tables: dict[str, list[dict]] + files: dict[str, Path] = field(default_factory=dict) + missing: list[str] = field(default_factory=list) + not_included: dict[str, str] = field(default_factory=dict) + + +def _info(name: str, size: int) -> tarfile.TarInfo: + info = tarfile.TarInfo(name) + info.size = size + info.mtime = 0 + info.mode = 0o644 + info.uid = info.gid = 0 + info.uname = info.gname = "" + return info + + +def write_archive_to(contents: Contents, out: IO[bytes], *, app_version: str, at: datetime) -> dict: + """Write the backup tar to an open binary file. Returns the manifest. + + GNU format, because an attachment's path can run past USTAR's 100 characters, + and GNU long names are what tar and restic both read.""" + names = sorted(contents.tables) + paths = sorted(contents.files) + sizes = {p: contents.files[p].stat().st_size for p in paths} + manifest = { + "format": ARCHIVE_FORMAT, + "format_version": ARCHIVE_FORMAT_VERSION, + "app_version": app_version, + "exported_at": at.astimezone(timezone.utc).isoformat(), + "tables": {name: len(contents.tables[name]) for name in names}, + "files": len(paths), + "file_bytes": sum(sizes.values()), + "missing_files": sorted(contents.missing), + "not_included": contents.not_included, + } + with tarfile.open(fileobj=out, mode="w", format=tarfile.GNU_FORMAT) as tar: + data = (json.dumps(manifest, sort_keys=True, indent=2) + "\n").encode() + tar.addfile(_info(MANIFEST_NAME, len(data)), io.BytesIO(data)) + for name in names: + data = b"".join(encode_row(row) for row in contents.tables[name]) + tar.addfile(_info(TABLES_DIR + name + TABLE_SUFFIX, len(data)), io.BytesIO(data)) + for path in paths: + with open(contents.files[path], "rb") as f: + tar.addfile(_info(FILES_DIR + path, sizes[path]), f) + return manifest + + +def write_archive(contents: Contents, directory: Path, at: datetime, *, app_version: str) -> tuple[Path, dict]: + """Write the backup into `directory` under its dated name, atomically. + + The bytes go to a dot-named `.part` file first, are fsync'd, and only then take + the final name. A watcher of the folder never sees half a backup under a backup's + name, and a run that fails leaves nothing behind but its error.""" + final = directory / archive_name(at) + part = directory / f".{final.name}.part" + try: + with open(part, "wb") as f: + manifest = write_archive_to(contents, f, app_version=app_version, at=at) + f.flush() + os.fsync(f.fileno()) + os.replace(part, final) + except BaseException: + part.unlink(missing_ok=True) + raise + _fsync_dir(directory) + return final, manifest + + +def _fsync_dir(directory: Path) -> None: + """Make the rename itself durable. Some filesystems (and NFS mounts) refuse a + directory fsync; the file's own fsync already happened, so that refusal is not a + failure of the backup.""" + try: + fd = os.open(directory, os.O_RDONLY) + except OSError: + return + try: + os.fsync(fd) + except OSError: + pass + finally: + os.close(fd) + + +# ---- reading it back ----------------------------------------------------------------- + + +class ArchiveError(ValueError): + """The file is not a backup this version can read, or it is damaged.""" + + +def safe_media_path(name: str) -> str: + """A `files/` member's path, checked to stay inside the media root. A crafted + archive could otherwise name `../../etc/…` and write wherever the server can.""" + rel = PurePosixPath(name) + if rel.is_absolute() or not rel.parts or any(p in ("..", "") for p in rel.parts): + raise ArchiveError(f"refusing a file path that leaves the media folder: {name}") + return str(rel) + + +@dataclass +class Member: + kind: str # "table" | "file" + name: str # the table name, or the path under the media root + fileobj: IO[bytes] + + +def read_archive(fileobj: IO[bytes]) -> tuple[dict, Iterator[Member]]: + """Open a backup tar as a stream: its manifest, then its members in order. + + Streams, so a backup of many gigabytes of attachments is never held in memory. + The caller must consume each member's `fileobj` before asking for the next.""" + try: + tar = tarfile.open(fileobj=fileobj, mode="r|") + first = tar.next() + except tarfile.TarError as exc: + raise ArchiveError(f"not a backup archive: {exc}") from exc + if first is None or first.name != MANIFEST_NAME: + raise ArchiveError("not an Inkwell backup: it doesn't start with a manifest") + raw = tar.extractfile(first) + manifest = json.loads(raw.read() if raw else b"{}") + if manifest.get("format") != ARCHIVE_FORMAT: + raise ArchiveError("not an Inkwell backup (no Inkwell manifest)") + if manifest.get("format_version", 0) > ARCHIVE_FORMAT_VERSION: + raise ArchiveError( + f"this backup's format ({manifest['format_version']}) is newer than this " + f"Inkwell reads ({ARCHIVE_FORMAT_VERSION}); upgrade first" + ) + + def members() -> Iterator[Member]: + with tar: + for info in tar: + if not info.isfile(): + continue + f = tar.extractfile(info) + if f is None: + continue + if info.name.startswith(TABLES_DIR) and info.name.endswith(TABLE_SUFFIX): + yield Member("table", info.name[len(TABLES_DIR) : -len(TABLE_SUFFIX)], f) + elif info.name.startswith(FILES_DIR): + yield Member("file", safe_media_path(info.name[len(FILES_DIR) :]), f) + + return manifest, members() + + +def read_rows(member: Member) -> list[dict]: + return [json.loads(line) for line in member.fileobj.read().splitlines() if line] diff --git a/src/inkwell/backup_job.py b/src/inkwell/backup_job.py new file mode 100644 index 0000000..37bcae1 --- /dev/null +++ b/src/inkwell/backup_job.py @@ -0,0 +1,247 @@ +"""The daily backup: when it runs, running it, and what happened (#5920). + +- **When.** A tick every few minutes asks whether a backup is due: switched on, and + no good backup since the most recent scheduled hour. A server that was down at + that hour backs up as soon as it is back. Switching backups on runs the first one + within a tick, which is also the quickest proof the destination works. +- **Failure is "not yet".** A failed run is tried again after an hour, then two, + then four, then waits for the next day's hour, rather than at once or never + (rule 193). The destination may simply be restarting. +- **One at a time.** An in-process lock, plus a Postgres advisory lock in case a + second server process is ever started against the same database. Back up now + while one is running is answered 409. +- **Bounded.** Every run has a deadline, and the history keeps the last 30 runs. +""" +from __future__ import annotations + +import asyncio +import logging +from contextlib import suppress +from datetime import datetime, timedelta, timezone + +from sqlalchemy import text + +from .backup_data import BACKUP_RUNS_SETTING, export_contents +from .backup_destination import DestinationError, check, deliver, load_destination +from .db import session_scope +from .settings import get_internal, get_setting, set_internal + +logger = logging.getLogger(__name__) + +RUNS_KEPT = 30 +DEADLINE_SECONDS = 60 * 60 +TICK_SECONDS = 5 * 60 +STARTUP_DELAY_SECONDS = 90 +# A good backup older than this is overdue: a day, plus room for a slow run. +OVERDUE_AFTER = timedelta(hours=26) +RETRY_FIRST = timedelta(hours=1) +TRIES_PER_DAY = 4 +# Any fixed number, the same in every process: pg_try_advisory_lock's key. +ADVISORY_KEY = 0x1A4B_BAC0 + + +class AlreadyRunning(Exception): + pass + + +_lock = asyncio.Lock() +_running_since: datetime | None = None +_tasks: set[asyncio.Task] = set() + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _parse(stamp: str | None) -> datetime | None: + if not stamp: + return None + try: + return datetime.fromisoformat(stamp) + except ValueError: + return None + + +def last_slot(now: datetime, hour: int) -> datetime: + """The most recent scheduled hour at or before `now`.""" + slot = now.replace(hour=hour, minute=0, second=0, microsecond=0) + return slot if slot <= now else slot - timedelta(days=1) + + +def is_due(now: datetime, hour: int, runs: list[dict]) -> bool: + """Whether the schedule wants a backup now, given the run history (newest first). + + Pure, so the retry ladder can be tested without a clock.""" + slot = last_slot(now, hour) + since = [r for r in runs if (_parse(r.get("started_at")) or slot) >= slot] + if any(r.get("ok") for r in since): + return False + failures = [r for r in since if not r.get("ok")] + if not failures: + return True + if len(failures) >= TRIES_PER_DAY: + return False + last = _parse(failures[0].get("finished_at")) or now + return now >= last + RETRY_FIRST * (2 ** (len(failures) - 1)) + + +async def _runs(db) -> list[dict]: + runs = await get_internal(db, BACKUP_RUNS_SETTING, []) + return runs if isinstance(runs, list) else [] + + +async def _record(run: dict) -> None: + async with session_scope() as db: + runs = [run, *await _runs(db)][:RUNS_KEPT] + await set_internal(db, BACKUP_RUNS_SETTING, runs) + await db.commit() + + +async def run_backup(trigger: str, *, app_version: str) -> dict: + """Make one backup and send it. Returns the run as recorded. + + Raises AlreadyRunning when one is in progress here or in another process.""" + global _running_since + if _lock.locked(): + raise AlreadyRunning() + async with _lock: + async with session_scope() as guard: + if not await guard.scalar(text("SELECT pg_try_advisory_lock(:k)"), {"k": ADVISORY_KEY}): + raise AlreadyRunning() + try: + _running_since = started = _now() + run: dict = {"started_at": started.isoformat(), "trigger": trigger, "ok": False} + try: + delivered = await asyncio.wait_for(_backup(started, app_version), DEADLINE_SECONDS) + run.update( + ok=True, + name=delivered.name, + size_bytes=delivered.size_bytes, + where=delivered.where, + files=delivered.manifest.get("files", 0), + missing_files=len(delivered.manifest.get("missing_files", [])), + ) + logger.info("backup %s written to %s (%d bytes)", delivered.name, delivered.where, delivered.size_bytes) + except DestinationError as exc: + run["error"] = str(exc) + except TimeoutError: + run["error"] = f"It took longer than {DEADLINE_SECONDS // 60} minutes and was stopped." + except Exception as exc: # anything else is still a failed run, said plainly + logger.exception("backup failed") + run["error"] = f"Failed: {exc}" + if not run["ok"]: + logger.warning("backup failed: %s", run["error"]) + run["finished_at"] = _now().isoformat() + await _record(run) + return run + finally: + _running_since = None + await guard.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": ADVISORY_KEY}) + + +async def _backup(at: datetime, app_version: str): + async with session_scope() as db: + # One read-only snapshot: every table as of the same moment, so a note and + # its tags can't come from two different points in time. It has to be the + # transaction's first statement, before any read. + await db.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY")) + dest = await load_destination(db) + contents = await export_contents(db) + await db.rollback() + return await deliver(contents, dest, at, app_version=app_version) + + +def start_now(*, app_version: str) -> None: + """Back up now, in the background. Raises AlreadyRunning if one is going.""" + if _lock.locked(): + raise AlreadyRunning() + + async def job() -> None: + with suppress(AlreadyRunning): + await run_backup("manual", app_version=app_version) + + task = asyncio.create_task(job()) + _tasks.add(task) + task.add_done_callback(_tasks.discard) + + +async def drain() -> None: + """Wait for background runs. For tests.""" + while _tasks: + await asyncio.gather(*list(_tasks), return_exceptions=True) + + +async def check_destination() -> str: + async with session_scope() as db: + dest = await load_destination(db) + return await check(dest) + + +async def status() -> dict: + """Everything Settings → Backups shows, with its verdict worked out here.""" + now = _now() + async with session_scope() as db: + enabled = await get_setting(db, "backup_enabled") + hour = await get_setting(db, "backup_hour") + dest = await load_destination(db) + runs = await _runs(db) + last_ok = next((r for r in runs if r.get("ok")), None) + last = runs[0] if runs else None + ok_at = _parse(last_ok.get("finished_at")) if last_ok else None + if not enabled: + state = "off" + elif dest.problems(): + state = "incomplete" + elif last is not None and not last.get("ok"): + state = "failing" + elif ok_at is None: + state = "waiting" + elif now - ok_at > OVERDUE_AFTER: + state = "overdue" + else: + state = "ok" + # When the newest failure began a streak: the since-time a problem is shown with. + failing_since = None + for r in runs: + if r.get("ok"): + break + failing_since = r.get("started_at") + next_slot = last_slot(now, hour) + timedelta(days=1) + return { + "state": state, + "enabled": enabled, + "hour": hour, + "where": dest.where(), + "destination": dest.kind, + "problems": dest.problems(), + "running_since": _running_since.isoformat() if _running_since else None, + "last_ok": last_ok, + "failing_since": failing_since, + "next_run_at": next_slot.isoformat() if enabled else None, + "runs": runs, + } + + +async def tick(app_version: str) -> None: + async with session_scope() as db: + if not await get_setting(db, "backup_enabled"): + return + hour = await get_setting(db, "backup_hour") + runs = await _runs(db) + if is_due(_now(), hour, runs): + with suppress(AlreadyRunning): + await run_backup("schedule", app_version=app_version) + + +async def run_scheduler(app_version: str) -> None: + """The background loop. Started in `before_serving`, cancelled on shutdown. A + failing tick never ends the loop.""" + await asyncio.sleep(STARTUP_DELAY_SECONDS) + while True: + try: + await tick(app_version) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("backup scheduler tick failed; will retry next tick") + await asyncio.sleep(TICK_SECONDS) diff --git a/src/inkwell/backup_s3.py b/src/inkwell/backup_s3.py new file mode 100644 index 0000000..da330a1 --- /dev/null +++ b/src/inkwell/backup_s3.py @@ -0,0 +1,240 @@ +"""A small S3 client for sending backups: AWS Signature Version 4, path-style +addressing (endpoint/bucket/key), and the two calls a backup needs. + +Ported from FabledSteward's and FabledScribe's backup clients (Scribe snippet #5679), +which are tested against the same AWS examples as tests/test_backup_s3.py. Those use +httpx. This one uses the standard library's `http.client`, on a worker thread, +because Inkwell has no HTTP client dependency and two calls don't justify adding one. + +Inkwell puts and lists; it never deletes. The bucket keeps the history and thins it +by its own policy, so a key that may only add objects is enough. It is also the safer +key to give a service: a stolen one cannot wipe the history it writes into. +""" +from __future__ import annotations + +import hashlib +import hmac +import http.client +import ssl +import time +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import IO +from urllib.parse import parse_qsl, quote, urlencode, urlsplit + +EMPTY_SHA256 = hashlib.sha256(b"").hexdigest() +TIMEOUT_SECONDS = 30 +CHUNK = 1 << 20 + + +class S3Error(Exception): + """Why a call to the bucket failed, in words a person can act on.""" + + +@dataclass(frozen=True) +class S3Target: + endpoint: str # http(s)://host[:port][/prefix], without the bucket + bucket: str + access_key: str + secret_key: str + region: str = "us-east-1" + + def url(self, key: str = "") -> str: + return f"{self.endpoint.rstrip('/')}/{self.bucket}/{key}" + + +@dataclass(frozen=True) +class StoredObject: + key: str + size: int + last_modified: str + + +# ---- signing ----------------------------------------------------------------------- + + +def _encode(text: str, keep_slash: bool = False) -> str: + return quote(text, safe="-_.~" + ("/" if keep_slash else "")) + + +def _hmac(key: bytes, msg: str) -> bytes: + return hmac.new(key, msg.encode(), hashlib.sha256).digest() + + +def sign( + method: str, + url: str, + headers: dict[str, str], + payload_sha256: str, + access_key: str, + secret_key: str, + region: str, + at: datetime, + service: str = "s3", +) -> dict[str, str]: + """The request's headers with Host, x-amz-date, x-amz-content-sha256 and + Authorization added. Every header given is signed. Pure, so it can be held to + AWS's published examples.""" + parts = urlsplit(url) + stamp = at.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + day = stamp[:8] + out = dict(headers) + out["Host"] = parts.netloc + out["x-amz-date"] = stamp + out["x-amz-content-sha256"] = payload_sha256 + signed = {k.lower(): " ".join(str(v).split()) for k, v in out.items()} + names = sorted(signed) + + query = sorted((_encode(k), _encode(v)) for k, v in parse_qsl(parts.query, keep_blank_values=True)) + canonical = "\n".join( + [ + method.upper(), + _encode(parts.path or "/", keep_slash=True), + "&".join(f"{k}={v}" for k, v in query), + "".join(f"{n}:{signed[n]}\n" for n in names), + ";".join(names), + payload_sha256, + ] + ) + scope = f"{day}/{region}/{service}/aws4_request" + to_sign = "\n".join(["AWS4-HMAC-SHA256", stamp, scope, hashlib.sha256(canonical.encode()).hexdigest()]) + key = _hmac(_hmac(_hmac(_hmac(f"AWS4{secret_key}".encode(), day), region), service), "aws4_request") + signature = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest() + out["Authorization"] = ( + f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, " + f"SignedHeaders={';'.join(names)}, Signature={signature}" + ) + return out + + +# ---- calls --------------------------------------------------------------------------- + + +def _says(status: int, reason: str, body: bytes) -> str: + """An S3 error response in words: its Code and Message when it has them.""" + try: + root = ET.fromstring(body) + code = root.findtext("Code") or "" + message = root.findtext("Message") or "" + except ET.ParseError: + code, message = "", "" + hints = { + "SignatureDoesNotMatch": "the secret key is wrong", + "InvalidAccessKeyId": "the access key isn't known there", + "NoSuchBucket": "there is no such bucket", + "AccessDenied": "the key isn't allowed to do that", + } + said = hints.get(code) or message or reason + if status == 404 and not code: + # Seen live: an address without its port lands on port 80 and gets a plain + # 404 from whatever answers there (Scribe #5871). + said = "nothing answered as S3 at that address; check the port" + return f"HTTP {status}{f' {code}' if code else ''}: {said}." + + +class _Throttled: + """A file wrapper that reads no faster than `limit` bytes a second, so an upload + leaves room on the connection for everything else (rule 193).""" + + def __init__(self, f: IO[bytes], limit: int): + self._f = f + self._limit = limit + self._start = time.monotonic() + self._sent = 0 + + def read(self, n: int = -1) -> bytes: + data = self._f.read(n if n and n > 0 else CHUNK) + self._sent += len(data) + ahead = self._sent / self._limit - (time.monotonic() - self._start) + if ahead > 0: + time.sleep(ahead) + return data + + +def _connection(url: str, timeout: float) -> tuple[http.client.HTTPConnection, str]: + parts = urlsplit(url) + if parts.scheme == "https": + conn: http.client.HTTPConnection = http.client.HTTPSConnection( + parts.hostname or "", parts.port, timeout=timeout, context=ssl.create_default_context() + ) + else: + conn = http.client.HTTPConnection(parts.hostname or "", parts.port, timeout=timeout) + target = parts.path or "/" + if parts.query: + target += "?" + parts.query + return conn, target + + +def _request(target: S3Target, url: str, method: str, headers: dict, body=None, timeout: float = TIMEOUT_SECONDS): + conn, path = _connection(url, timeout) + try: + conn.request(method, path, body=body, headers=headers) + response = conn.getresponse() + return response.status, response.reason, response.read() + except TimeoutError: + raise S3Error(f"{target.endpoint} didn't answer in time.") from None + except (OSError, http.client.HTTPException) as exc: + raise S3Error(f"Couldn't reach {target.endpoint}: {exc}.") from None + finally: + conn.close() + + +def file_sha256(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + while chunk := f.read(CHUNK): + h.update(chunk) + return h.hexdigest() + + +def put_file(target: S3Target, key: str, path: Path, *, limit_kbps: int = 0, now: datetime | None = None) -> None: + """Upload one file as one object, streamed from disk. Blocking: run it on a thread. + + `limit_kbps` caps the rate (0 = none). The socket timeout applies to each read + and write, not the whole upload, so a slow but moving upload is not cut off.""" + digest = file_sha256(path) + size = path.stat().st_size + url = target.url(key) + headers = sign( + "PUT", + url, + {"Content-Length": str(size), "Content-Type": "application/x-tar"}, + digest, + target.access_key, + target.secret_key, + target.region, + now or datetime.now(timezone.utc), + ) + with open(path, "rb") as f: + body = _Throttled(f, limit_kbps * 1024) if limit_kbps > 0 else f + status, reason, data = _request(target, url, "PUT", headers, body=body, timeout=120) + if status // 100 != 2: + raise S3Error(f"Upload of {key} refused: {_says(status, reason, data)}") + + +def list_objects(target: S3Target, prefix: str = "", limit: int = 1000, *, now: datetime | None = None) -> list[StoredObject]: + """Up to `limit` objects under `prefix` (ListObjectsV2, one page). Blocking.""" + params = {"list-type": "2", "max-keys": str(limit)} + if prefix: + params["prefix"] = prefix + url = f"{target.url()}?{urlencode(params)}" + headers = sign( + "GET", url, {}, EMPTY_SHA256, target.access_key, target.secret_key, target.region, + now or datetime.now(timezone.utc), + ) + status, reason, data = _request(target, url, "GET", headers) + if status != 200: + raise S3Error(f"Listing {target.bucket} refused: {_says(status, reason, data)}") + try: + root = ET.fromstring(data) + except ET.ParseError: + raise S3Error(f"{target.endpoint} answered a listing with something that isn't S3's XML.") from None + out = [] + for item in root.iter(): + if item.tag.rsplit("}", 1)[-1] != "Contents": + continue + fields = {child.tag.rsplit("}", 1)[-1]: (child.text or "") for child in item} + out.append(StoredObject(fields.get("Key", ""), int(fields.get("Size") or 0), fields.get("LastModified", ""))) + return out diff --git a/src/inkwell/config.py b/src/inkwell/config.py index a801c7b..ddc1471 100644 --- a/src/inkwell/config.py +++ b/src/inkwell/config.py @@ -29,6 +29,10 @@ class Config: "postgresql+asyncpg://inkwell:inkwell@localhost:5432/inkwell", ) + @classmethod + def data_root(cls) -> Path: + return Path(cls.DATA_DIR) + @classmethod def media_root(cls) -> Path: return Path(cls.DATA_DIR) / "media" diff --git a/src/inkwell/restore.py b/src/inkwell/restore.py new file mode 100644 index 0000000..448be3c --- /dev/null +++ b/src/inkwell/restore.py @@ -0,0 +1,75 @@ +"""Bring a fresh Inkwell back from a backup file (#5921). + + docker compose exec app python -m inkwell.restore /backups/inkwell-20261016T020000Z.tar + +Run it against a NEW instance: one that has started once (so its database schema +exists) and has no accounts yet. It refuses a database that already holds an +account, because loading one instance's rows over another's would mix two sets of +notes with no way to pull them apart again. + +What comes back: every account, note, tag, share, group, invite and setting, and the +attachment files. What doesn't (see backup_data.py): credentials such as the SMTP +password and the backup bucket's secret key, which a backup never carries, so they +are entered again in Settings; and the signing key, so everyone signs in again. +Linked devices keep their tokens but should re-sync from scratch. +""" +from __future__ import annotations + +import argparse +import asyncio +import sys +from pathlib import Path + +from .backup_data import instance_is_empty, restore_tables +from .backup_file import ArchiveError, read_archive +from .db import dispose_engine, session_scope + + +async def restore(path: Path) -> dict[str, int]: + with open(path, "rb") as f: + manifest, members = read_archive(f) + async with session_scope() as db: + if not await instance_is_empty(db): + raise SystemExit( + "This instance already has accounts. Restore into a new, empty Inkwell: " + "start it once so its database exists, then run this before anyone signs up." + ) + counts = await restore_tables(db, members) + await db.commit() + expected = manifest.get("tables", {}) + short = {name: (counts.get(name, 0), n) for name, n in expected.items() if counts.get(name, 0) != n} + if short: + # Committed already: what did load is better kept than thrown away, but the + # person has to know the backup was not whole. + print(f"Warning: some tables loaded fewer rows than the backup lists: {short}", file=sys.stderr) + return counts + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="python -m inkwell.restore", description=__doc__.split("\n\n")[0]) + parser.add_argument("backup", type=Path, help="an inkwell-