AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
18 changed files with 2069 additions and 13 deletions
Showing only changes of commit edcb5c4c11 - Show all commits
+7 -7
View File
@@ -14,13 +14,10 @@
# Every push publishes an immutable :<sha> 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.
#
+125
View File
@@ -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/<table>.jsonl one row per line, in primary-key order
files/<path> 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.
+234
View File
@@ -0,0 +1,234 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { formatExact, timeAgo } from "../notes/datetime";
import { roughSize } from "../size";
import { useUiStore } from "../stores/ui";
// Settings → Backups, above the fields: whether backups are happening, what is wrong
// when they aren't, the two buttons, and the recent runs (#5920). Verdict first.
interface Run {
started_at: string;
finished_at?: string;
trigger: "schedule" | "manual";
ok: boolean;
name?: string;
size_bytes?: number;
where?: string;
files?: number;
missing_files?: number;
error?: string;
}
interface Status {
state: "off" | "incomplete" | "waiting" | "ok" | "overdue" | "failing";
enabled: boolean;
where: string;
problems: string[];
running_since: string | null;
last_ok: Run | null;
failing_since: string | null;
next_run_at: string | null;
runs: Run[];
}
// Bumped by the page after a save, so the verdict follows the fields.
const props = defineProps<{ refreshKey: number }>();
const ui = useUiStore();
const status = ref<Status | null>(null);
const loadError = ref("");
async function load(): Promise<void> {
try {
status.value = await api.get<Status>("/api/backups");
loadError.value = "";
} catch (e) {
loadError.value = errorMessage(e, "Couldn't load the backup status.");
}
}
watch(() => props.refreshKey, () => void load());
// While a backup runs the page checks every few seconds, and stops by itself: when
// the run ends, when the page closes, or after the run's own one-hour deadline.
const POLL_MS = 3000;
const POLL_LIMIT_MS = 65 * 60 * 1000;
let poll: ReturnType<typeof setTimeout> | null = null;
let pollStarted = 0;
function stopPolling(): void {
if (poll !== null) clearTimeout(poll);
poll = null;
}
function pollWhileRunning(): void {
stopPolling();
if (!status.value?.running_since || Date.now() - pollStarted > POLL_LIMIT_MS) return;
poll = setTimeout(async () => {
try {
await load();
} finally {
pollWhileRunning();
}
}, POLL_MS);
}
watch(
() => status.value?.running_since,
(since) => {
if (since && poll === null) {
pollStarted = Date.now();
pollWhileRunning();
}
},
);
onBeforeUnmount(stopPolling);
const starting = ref(false);
async function backUpNow(): Promise<void> {
starting.value = true;
try {
await api.post("/api/backups/run");
ui.showToast("Backup started.");
await load();
// It may already be finished; if not, the watch above starts polling.
} catch (e) {
ui.showToast(errorMessage(e, "Couldn't start a backup."));
} finally {
starting.value = false;
}
}
const testing = ref(false);
const testResult = ref<{ ok: boolean; text: string } | null>(null);
async function testDestination(): Promise<void> {
testing.value = true;
testResult.value = null;
try {
const res = await api.post<{ message: string }>("/api/backups/test");
testResult.value = { ok: true, text: res.message };
} catch (e) {
testResult.value = { ok: false, text: errorMessage(e, "The destination didn't answer.") };
} finally {
testing.value = false;
}
}
function size(bytes?: number): string {
if (bytes === undefined) return "—";
return bytes >= 1024 ** 3 ? roughSize(bytes, "GB") : roughSize(bytes, "MB");
}
/** The one line that answers "are my notes backed up?". */
const verdict = computed(() => {
const s = status.value;
if (!s) return null;
const last = s.runs[0];
if (s.running_since) return { tone: "neutral", text: `Backing up now, started ${timeAgo(s.running_since)}.` };
switch (s.state) {
case "off":
return { tone: "neutral", text: "Daily backups are off. Switch them on below; the first one runs a few minutes later." };
case "incomplete":
return { tone: "warn", text: `Backups can't run yet. ${s.problems.join(" ")}` };
case "waiting":
return { tone: "neutral", text: `The first backup to ${s.where} runs within a few minutes.` };
case "failing":
return {
tone: "bad",
text: `Backups have been failing since ${formatExact(s.failing_since)}: ${last?.error ?? "unknown error"}`,
};
case "overdue":
return {
tone: "warn",
text: `Overdue: the last good backup was ${timeAgo(s.last_ok?.finished_at ?? null)}.`,
};
default:
return {
tone: "good",
text: `Last backup ${timeAgo(s.last_ok?.finished_at ?? null)}, ${size(s.last_ok?.size_bytes)}, to ${s.last_ok?.where}.`,
};
}
});
const TONES: Record<string, string> = {
good: "border-green-300 bg-green-50 text-green-900 dark:border-green-900 dark:bg-green-950/40 dark:text-green-200",
warn: "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200",
bad: "border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200",
neutral: "border-neutral-200 bg-neutral-50 text-neutral-700 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-300",
};
// A run that left out files the database names: the backup is good but not whole.
const missingNote = computed(() => {
const n = status.value?.last_ok?.missing_files ?? 0;
return n ? `${n} attachment file${n === 1 ? " was" : "s were"} missing from disk and couldn't be included.` : "";
});
const showAll = ref(false);
const runs = computed(() => (showAll.value ? status.value?.runs ?? [] : (status.value?.runs ?? []).slice(0, 5)));
onMounted(load);
</script>
<template>
<section class="flex flex-col gap-4">
<p v-if="loadError" class="form-error">{{ loadError }}</p>
<p v-else-if="!status" class="list-empty">Loading…</p>
<template v-else-if="verdict">
<p class="rounded-lg border px-3 py-2 text-sm" :class="TONES[verdict.tone]">
{{ verdict.text }}
<span v-if="status.next_run_at && status.state !== 'incomplete'" class="block text-xs opacity-80">
Next scheduled backup: {{ formatExact(status.next_run_at) }}.
</span>
</p>
<p v-if="missingNote" class="text-xs text-amber-700 dark:text-amber-400">{{ missingNote }}</p>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="btn-sm"
:disabled="testing || status.problems.length > 0"
@click="testDestination"
>
{{ testing ? "Checking…" : "Test destination" }}
</button>
<button
type="button"
class="btn-sm"
:disabled="starting || !!status.running_since || status.problems.length > 0"
@click="backUpNow"
>
Back up now
</button>
<span
v-if="testResult"
class="text-xs"
:class="testResult.ok ? 'text-green-700 dark:text-green-400' : 'text-red-600 dark:text-red-400'"
>{{ testResult.text }}</span
>
</div>
<div v-if="status.runs.length" class="flex flex-col gap-1">
<h3 class="section-label">Recent backups</h3>
<ul class="flex flex-col divide-y divide-neutral-100 text-sm dark:divide-neutral-800">
<li v-for="r in runs" :key="r.started_at" class="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 py-1.5">
<span
class="h-2 w-2 shrink-0 self-center rounded-full"
:class="r.ok ? 'bg-green-500' : 'bg-red-500'"
:aria-label="r.ok ? 'succeeded' : 'failed'"
></span>
<span class="tabular-nums text-neutral-600 dark:text-neutral-300" :title="r.started_at">{{
formatExact(r.started_at)
}}</span>
<span class="text-xs text-neutral-400">{{ r.trigger === "manual" ? "by hand" : "scheduled" }}</span>
<span v-if="r.ok" class="ml-auto tabular-nums text-neutral-500">{{ size(r.size_bytes) }}</span>
<span v-if="!r.ok" class="w-full text-xs text-red-600 dark:text-red-400">{{ r.error }}</span>
</li>
</ul>
<button
v-if="status.runs.length > 5"
type="button"
class="self-start text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"
@click="showAll = !showAll"
>
{{ showAll ? "Show fewer" : `Show all ${status.runs.length}` }}
</button>
</div>
</template>
</section>
</template>
+28
View File
@@ -1,3 +1,5 @@
import { counted } from "../counted";
// Reminder datetime helpers. Reminders are stored as ISO UTC; the editor's
// <input type="datetime-local"> 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",
});
}
+18
View File
@@ -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<string, ServerSetting[]>();
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);
<template v-else>Email is on, sending as {{ mailSender }}.</template>
</p>
<!-- Backups lead with whether they are happening, as Email does. -->
<BackupPanel v-if="tab === 'backups'" :refresh-key="savedCount" />
<form v-if="groups.length" class="flex flex-col gap-8" @submit.prevent="save">
<section v-for="group in groups" :key="group.name" class="flex flex-col gap-5">
<h2 class="section-label">{{ group.name }}</h2>
+13 -6
View File
@@ -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):
+39
View File
@@ -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)
+169
View File
@@ -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)
+175
View File
@@ -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"
+216
View File
@@ -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/<table>.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/<path>`, 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]
+247
View File
@@ -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)
+240
View File
@@ -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
+4
View File
@@ -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"
+75
View File
@@ -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-<time>.tar backup file")
args = parser.parse_args(argv)
if not args.backup.is_file():
print(f"{args.backup} isn't a file.", file=sys.stderr)
return 2
async def go() -> dict[str, int]:
try:
return await restore(args.backup)
finally:
await dispose_engine()
try:
counts = asyncio.run(go())
except ArchiveError as exc:
print(f"Can't restore: {exc}.", file=sys.stderr)
return 1
files = counts.pop("files", 0)
rows = sum(counts.values())
print(f"Restored {rows} rows across {len(counts)} tables, and {files} attachment files.")
print("Re-enter the SMTP password and the backup secret key in Settings; they are never in a backup.")
return 0
if __name__ == "__main__":
sys.exit(main())
+101
View File
@@ -154,6 +154,95 @@ REGISTRY: list[SettingDef] = [
"smtp_password", "string", "", "SMTP password", "Kept on the server and never shown again.", "Email", secret=True
),
SettingDef("smtp_from", "string", "", "From address", "Who emails come from. Defaults to the username.", "Email"),
# --- Backups (#5918) ------------------------------------------------------------
#
# A dated backup file each day, to a folder or an S3-compatible bucket
# (backup_job.py). Off until switched on, because a folder has to be mounted or a
# bucket made before there is anywhere to send it.
SettingDef(
"backup_enabled",
"bool",
False,
"Daily backups",
"Write a backup once a day at the hour below, and send it to the destination.",
"Backups",
),
SettingDef(
"backup_hour",
"int",
2,
"Hour (UTC)",
"When the daily backup runs, 0 to 23 in UTC. A backup missed while the server "
"was down runs as soon as it is back.",
"Backups",
minimum=0,
maximum=23,
),
SettingDef(
"backup_destination",
"string",
"folder",
"Destination",
"Where each backup file goes. Inkwell only ever adds files there; keeping and "
"thinning old ones is the destination's job.",
"Backups",
choices=("folder", "s3"),
choice_labels=("A folder (a mounted share)", "An S3-compatible bucket"),
),
SettingDef(
"backup_folder",
"string",
"/backups",
"Folder",
"For a folder: an absolute path inside the container, with a share mounted there.",
"Backups",
),
SettingDef(
"backup_s3_endpoint",
"string",
"",
"S3 address",
"For a bucket: the server's address with its port, like http://archives.lan:8081.",
"Backups",
url=True,
),
SettingDef("backup_s3_bucket", "string", "", "Bucket", "For a bucket: its name.", "Backups"),
SettingDef(
"backup_s3_region",
"string",
"us-east-1",
"Region",
"For a bucket: most self-hosted S3 servers accept us-east-1.",
"Backups",
),
SettingDef(
"backup_s3_access_key",
"string",
"",
"Access key",
"For a bucket. A key that may only add files is enough, and the safer one to give.",
"Backups",
),
SettingDef(
"backup_s3_secret_key",
"string",
"",
"Secret key",
"Kept on the server, never shown again, and never put in a backup.",
"Backups",
secret=True,
),
SettingDef(
"backup_upload_limit_kbps",
"int",
10240,
"Upload limit (KB/s)",
"How fast a backup is sent to a bucket, so it never crowds the network. The "
"default, 10 MB/s, sends 1 GB in under two minutes. 0 for no limit.",
"Backups",
minimum=0,
maximum=10_000_000,
),
# --- Security -----------------------------------------------------------------
#
# Read on paths too hot for a database round trip (the credential throttle checks
@@ -307,6 +396,18 @@ async def _upsert(db, key: str, value: Any) -> None:
row.updated_at = datetime.now(timezone.utc)
async def get_internal(db, key: str, default: Any = None) -> Any:
"""A setting the code keeps for itself (no registry row, never in the admin UI),
such as the backup run history."""
raw = await _load_raw(db, key)
return default if raw is None else raw
async def set_internal(db, key: str, value: Any) -> None:
"""Store an internal setting. The caller commits."""
await _upsert(db, key, value)
async def get_setting(db, key: str) -> Any:
defn = _BY_KEY.get(key)
if defn is None:
+172
View File
@@ -0,0 +1,172 @@
import io
import tarfile
from datetime import datetime, timedelta, timezone
import pytest
from inkwell import backup_file
from inkwell.backup_data import NOT_INCLUDED, SETTINGS_NOT_INCLUDED, exported_tables
from inkwell.backup_file import ArchiveError, Contents, read_archive, read_rows, safe_media_path, write_archive
from inkwell.backup_job import TRIES_PER_DAY, is_due, last_slot
from inkwell.models import Base
from inkwell.settings import REGISTRY, SECRET_KEY_SETTING
AT = datetime(2026, 10, 16, 2, 0, tzinfo=timezone.utc)
def _contents(tmp_path, notes=None):
media = tmp_path / "media"
(media / "ab").mkdir(parents=True, exist_ok=True)
(media / "ab" / "photo.png").write_bytes(b"\x89PNG not really")
return Contents(
tables={
"notes": notes or [{"id": "1", "body": "milk"}, {"id": "2", "body": "eggs"}],
"labels": [{"id": "9", "name": "grocery"}],
},
files={"ab/photo.png": media / "ab" / "photo.png"},
)
def _members(path) -> dict[str, bytes]:
with tarfile.open(path) as tar:
return {m.name: tar.extractfile(m).read() for m in tar.getmembers()}
# ---- what goes in ---------------------------------------------------------------------
def test_every_table_is_backed_up_or_named_as_left_out():
"""A table added later must not silently go missing from backups."""
every = {t.name for t in Base.metadata.sorted_tables}
backed_up = {t.name for t in exported_tables()}
assert set(NOT_INCLUDED) <= every, "NOT_INCLUDED names a table that doesn't exist"
assert backed_up | set(NOT_INCLUDED) == every
def test_no_credential_ever_enters_a_backup():
secrets = {d.key for d in REGISTRY if d.secret}
assert "backup_s3_secret_key" in secrets and "smtp_password" in secrets
assert secrets | {SECRET_KEY_SETTING} <= SETTINGS_NOT_INCLUDED
# ---- the file ---------------------------------------------------------------------------
def test_unchanged_data_writes_the_same_bytes_on_another_day(tmp_path):
out1, out2 = tmp_path / "one", tmp_path / "two"
out1.mkdir()
out2.mkdir()
first, _ = write_archive(_contents(tmp_path), out1, AT, app_version="a")
second, _ = write_archive(_contents(tmp_path), out2, AT + timedelta(days=1), app_version="b")
a, b = _members(first), _members(second)
assert a.keys() == b.keys()
for name in a:
if name != "manifest.json":
assert a[name] == b[name], name
assert a["manifest.json"] != b["manifest.json"]
def test_one_edit_changes_one_line_of_one_member(tmp_path):
(tmp_path / "one").mkdir()
(tmp_path / "two").mkdir()
before, _ = write_archive(_contents(tmp_path), tmp_path / "one", AT, app_version="a")
edited = [{"id": "1", "body": "oat milk"}, {"id": "2", "body": "eggs"}]
after, _ = write_archive(_contents(tmp_path, edited), tmp_path / "two", AT, app_version="a")
a, b = _members(before), _members(after)
changed = [n for n in a if a[n] != b[n]]
assert changed == ["tables/notes.jsonl"]
lines_a, lines_b = a[changed[0]].splitlines(), b[changed[0]].splitlines()
assert sum(x != y for x, y in zip(lines_a, lines_b)) == 1
def test_members_come_in_a_fixed_order_with_fixed_headers(tmp_path):
(tmp_path / "out").mkdir()
path, _ = write_archive(_contents(tmp_path), tmp_path / "out", AT, app_version="a")
with tarfile.open(path) as tar:
members = tar.getmembers()
assert [m.name for m in members] == [
"manifest.json",
"tables/labels.jsonl",
"tables/notes.jsonl",
"files/ab/photo.png",
]
assert {(m.mtime, m.uid, m.gid, m.mode) for m in members} == {(0, 0, 0, 0o644)}
assert path.name == "inkwell-20261016T020000Z.tar"
def test_nothing_appears_under_the_final_name_when_writing_fails(tmp_path):
contents = _contents(tmp_path)
contents.files["gone.png"] = tmp_path / "media" / "gone.png" # stat() fails
(tmp_path / "out").mkdir()
with pytest.raises(FileNotFoundError):
write_archive(contents, tmp_path / "out", AT, app_version="a")
assert list((tmp_path / "out").iterdir()) == []
def test_a_backup_reads_back_as_it_was_written(tmp_path):
(tmp_path / "out").mkdir()
path, _ = write_archive(_contents(tmp_path), tmp_path / "out", AT, app_version="v1")
with open(path, "rb") as f:
manifest, members = read_archive(f)
got = {}
for m in members:
got[(m.kind, m.name)] = read_rows(m) if m.kind == "table" else m.fileobj.read()
assert manifest["app_version"] == "v1"
assert manifest["tables"] == {"labels": 1, "notes": 2}
assert got[("table", "notes")][0] == {"body": "milk", "id": "1"}
assert got[("file", "ab/photo.png")] == b"\x89PNG not really"
def test_a_file_that_is_not_a_backup_is_refused():
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar:
tar.addfile(tarfile.TarInfo("other.txt"), io.BytesIO(b""))
buf.seek(0)
with pytest.raises(ArchiveError):
read_archive(buf)
@pytest.mark.parametrize("name", ["../etc/passwd", "/etc/passwd", "a/../../b", ""])
def test_a_file_path_that_leaves_the_media_folder_is_refused(name):
with pytest.raises(ArchiveError):
safe_media_path(name)
# ---- the schedule ---------------------------------------------------------------------
def _run(at: datetime, ok: bool) -> dict:
return {"started_at": at.isoformat(), "finished_at": (at + timedelta(minutes=1)).isoformat(), "ok": ok}
def test_the_slot_is_the_most_recent_scheduled_hour():
assert last_slot(datetime(2026, 10, 16, 1, 59, tzinfo=timezone.utc), 2) == datetime(
2026, 10, 15, 2, 0, tzinfo=timezone.utc
)
assert last_slot(AT, 2) == AT
def test_due_once_per_slot_and_a_failure_is_tried_again_later_not_at_once():
now = AT + timedelta(minutes=5)
assert is_due(now, 2, []) is True
assert is_due(now, 2, [_run(AT, ok=True)]) is False
# Yesterday's success doesn't count for today.
assert is_due(now, 2, [_run(AT - timedelta(days=1), ok=True)]) is True
failed = [_run(AT, ok=False)]
assert is_due(AT + timedelta(minutes=30), 2, failed) is False
assert is_due(AT + timedelta(hours=1, minutes=2), 2, failed) is True
# The ladder doubles: 1 h, then 2 h after the second failure.
twice = [_run(AT + timedelta(hours=1), ok=False), _run(AT, ok=False)]
assert is_due(AT + timedelta(hours=2), 2, twice) is False
assert is_due(AT + timedelta(hours=3, minutes=2), 2, twice) is True
# And stops for the day after its tries are used.
spent = [_run(AT + timedelta(hours=h), ok=False) for h in range(TRIES_PER_DAY)][::-1]
assert is_due(AT + timedelta(hours=20), 2, spent) is False
def test_the_archive_name_prefix_matches_what_a_destination_counts():
assert backup_file.archive_name(AT).startswith(backup_file.NAME_PREFIX)
assert backup_file.archive_name(AT).endswith(backup_file.NAME_SUFFIX)
+110
View File
@@ -0,0 +1,110 @@
import threading
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from inkwell.backup_s3 import EMPTY_SHA256, S3Error, S3Target, list_objects, put_file, sign
# AWS's published Signature Version 4 examples for S3 ("Signature Calculations for the
# Authorization Header: Transferring Payload in a Single Chunk").
AK = "AKIAIOSFODNN7EXAMPLE"
SK = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
AT = datetime(2013, 5, 24, tzinfo=timezone.utc)
def _signature(headers: dict) -> str:
return headers["Authorization"].rsplit("Signature=", 1)[1]
def test_get_object_matches_aws_example():
headers = sign(
"GET", "https://examplebucket.s3.amazonaws.com/test.txt", {"Range": "bytes=0-9"},
EMPTY_SHA256, AK, SK, "us-east-1", AT,
)
assert "SignedHeaders=host;range;x-amz-content-sha256;x-amz-date" in headers["Authorization"]
assert _signature(headers) == "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
def test_list_objects_matches_aws_example():
headers = sign(
"GET", "https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J", {},
EMPTY_SHA256, AK, SK, "us-east-1", AT,
)
assert _signature(headers) == "34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7"
# ---- against a local stand-in bucket ---------------------------------------------------
LISTING = b"""<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Contents><Key>inkwell-20261015T020000Z.tar</Key><Size>42</Size><LastModified>2026-10-15T02:00:05Z</LastModified></Contents>
</ListBucketResult>"""
DENIED = b"""<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>"""
@pytest.fixture
def bucket():
received: dict = {"puts": {}, "deny": False}
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_PUT(self):
length = int(self.headers["Content-Length"])
received["puts"][self.path] = (self.rfile.read(length), dict(self.headers))
if received["deny"]:
self.send_response(403)
self.end_headers()
self.wfile.write(DENIED)
return
self.send_response(200)
self.send_header("Content-Length", "0")
self.end_headers()
def do_GET(self):
received["get"] = self.path
self.send_response(200)
self.send_header("Content-Length", str(len(LISTING)))
self.end_headers()
self.wfile.write(LISTING)
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
received["target"] = S3Target(f"http://127.0.0.1:{server.server_address[1]}", "backups", AK, SK)
yield received
server.shutdown()
def test_a_file_is_put_whole_and_signed(bucket, tmp_path):
path = tmp_path / "inkwell-x.tar"
path.write_bytes(b"x" * 300_000)
put_file(bucket["target"], "inkwell-x.tar", path, limit_kbps=100_000)
body, headers = bucket["puts"]["/backups/inkwell-x.tar"]
assert body == b"x" * 300_000
assert headers["Authorization"].startswith(f"AWS4-HMAC-SHA256 Credential={AK}/")
assert headers["Host"] == bucket["target"].endpoint.split("//", 1)[1]
def test_a_refusal_says_why_in_words(bucket, tmp_path):
bucket["deny"] = True
path = tmp_path / "inkwell-x.tar"
path.write_bytes(b"x")
with pytest.raises(S3Error, match="AccessDenied: the key isn't allowed to do that"):
put_file(bucket["target"], "inkwell-x.tar", path)
def test_listing_reads_the_objects(bucket):
objects = list_objects(bucket["target"], "inkwell-")
assert [(o.key, o.size) for o in objects] == [("inkwell-20261015T020000Z.tar", 42)]
assert "list-type=2" in bucket["get"] and "prefix=inkwell-" in bucket["get"]
def test_an_address_nothing_answers_on_says_so():
target = S3Target("http://127.0.0.1:1", "backups", AK, SK)
with pytest.raises(S3Error, match="Couldn't reach"):
list_objects(target)
+96
View File
@@ -2099,3 +2099,99 @@ async def test_the_test_email_goes_to_the_admin_and_reports_a_failure(app_client
failed = await app_client.post("/api/settings/test-email")
assert failed.status_code == 502
assert "bad credentials" in (await failed.get_json())["error"]
# --- backups (#5918–#5921) --------------------------------------------------------------
async def _backup_settings_off() -> None:
"""Settings outlive the per-test truncate, so each backup test starts from none."""
async with session_scope() as fresh:
await fresh.execute(delete(Setting).where(Setting.key.like("backup%")))
await fresh.execute(delete(Setting).where(Setting.key == "smtp_password"))
await fresh.commit()
async def test_a_backup_is_written_to_a_folder_and_restores_into_an_empty_instance(app_client, db, tmp_path):
import tarfile
from inkwell import backup_job
from inkwell.restore import restore
await _backup_settings_off()
await _admin_with_invite(app_client)
note = await (await app_client.post("/api/notes", json={"body": "buy milk #grocery"})).get_json()
uploaded = await app_client.post(f"/api/notes/{note['id']}/attachments", files=_upload(b"photo bytes", "p.bin"))
assert uploaded.status_code == 201
folder = tmp_path / "backups"
folder.mkdir()
saved = await app_client.patch(
"/api/settings",
json={"backup_destination": "folder", "backup_folder": str(folder), "smtp_password": "hunter2"},
)
assert saved.status_code == 200, await saved.get_data(as_text=True)
checked = await app_client.post("/api/backups/test")
assert checked.status_code == 200, await checked.get_data(as_text=True)
assert "writable and holds 0 Inkwell backups" in (await checked.get_json())["message"]
assert (await app_client.post("/api/backups/run")).status_code == 202
await backup_job.drain()
status = await (await app_client.get("/api/backups")).get_json()
run = status["runs"][0]
assert run["ok"] is True, run
assert run["files"] == 1 and run["missing_files"] == 0
path = folder / run["name"]
assert path.is_file() and not list(folder.glob(".*"))
# No credential travels in a backup.
with tarfile.open(path) as tar:
settings_rows = tar.extractfile("tables/settings.jsonl").read().decode()
names = tar.getnames()
assert "hunter2" not in settings_rows and '"secret_key"' not in settings_rows
assert '"backup_folder"' in settings_rows
assert "tables/password_resets.jsonl" not in names
# A different instance: the database emptied and the attachments gone.
async with session_scope() as fresh:
await fresh.execute(text(f"TRUNCATE {_TABLES}, device_tokens RESTART IDENTITY CASCADE"))
await fresh.commit()
for f in Config.media_root().rglob("*"):
if f.is_file():
f.unlink()
counts = await restore(path)
assert counts["files"] == 1 and counts["notes"] == 1
async with session_scope() as fresh:
back = await fresh.get(Note, uuid.UUID(note["id"]))
assert back.body == "buy milk #grocery"
tags = (await fresh.execute(select(NoteLabel).where(NoteLabel.note_id == back.id))).scalars().all()
assert len(tags) == 1
att = await fresh.scalar(select(NoteAttachment).where(NoteAttachment.note_id == back.id))
assert (Config.media_root() / att.path).read_bytes() == b"photo bytes"
# And it refuses an instance that already has accounts.
with pytest.raises(SystemExit):
await restore(path)
await _backup_settings_off()
async def test_back_up_now_says_what_is_wrong_with_the_destination(app_client, db, tmp_path):
from inkwell import backup_job
await _backup_settings_off()
await _admin_with_invite(app_client)
await app_client.patch("/api/settings", json={"backup_folder": str(tmp_path / "not-mounted")})
tested = await app_client.post("/api/backups/test")
assert tested.status_code == 400
assert "isn't there" in (await tested.get_json())["error"]
assert (await app_client.post("/api/backups/run")).status_code == 202
await backup_job.drain()
status = await (await app_client.get("/api/backups")).get_json()
assert status["runs"][0]["ok"] is False
assert "isn't there" in status["runs"][0]["error"]
await _backup_settings_off()