AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
+7
-7
@@ -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
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -2099,3 +2099,99 @@ async def test_the_test_email_goes_to_the_admin_and_reports_a_failure(app_client
|
||||
failed = await app_client.post("/api/settings/test-email")
|
||||
assert failed.status_code == 502
|
||||
assert "bad credentials" in (await failed.get_json())["error"]
|
||||
|
||||
|
||||
# --- backups (#5918–#5921) --------------------------------------------------------------
|
||||
|
||||
|
||||
async def _backup_settings_off() -> None:
|
||||
"""Settings outlive the per-test truncate, so each backup test starts from none."""
|
||||
async with session_scope() as fresh:
|
||||
await fresh.execute(delete(Setting).where(Setting.key.like("backup%")))
|
||||
await fresh.execute(delete(Setting).where(Setting.key == "smtp_password"))
|
||||
await fresh.commit()
|
||||
|
||||
|
||||
async def test_a_backup_is_written_to_a_folder_and_restores_into_an_empty_instance(app_client, db, tmp_path):
|
||||
import tarfile
|
||||
|
||||
from inkwell import backup_job
|
||||
from inkwell.restore import restore
|
||||
|
||||
await _backup_settings_off()
|
||||
await _admin_with_invite(app_client)
|
||||
note = await (await app_client.post("/api/notes", json={"body": "buy milk #grocery"})).get_json()
|
||||
uploaded = await app_client.post(f"/api/notes/{note['id']}/attachments", files=_upload(b"photo bytes", "p.bin"))
|
||||
assert uploaded.status_code == 201
|
||||
|
||||
folder = tmp_path / "backups"
|
||||
folder.mkdir()
|
||||
saved = await app_client.patch(
|
||||
"/api/settings",
|
||||
json={"backup_destination": "folder", "backup_folder": str(folder), "smtp_password": "hunter2"},
|
||||
)
|
||||
assert saved.status_code == 200, await saved.get_data(as_text=True)
|
||||
|
||||
checked = await app_client.post("/api/backups/test")
|
||||
assert checked.status_code == 200, await checked.get_data(as_text=True)
|
||||
assert "writable and holds 0 Inkwell backups" in (await checked.get_json())["message"]
|
||||
|
||||
assert (await app_client.post("/api/backups/run")).status_code == 202
|
||||
await backup_job.drain()
|
||||
status = await (await app_client.get("/api/backups")).get_json()
|
||||
run = status["runs"][0]
|
||||
assert run["ok"] is True, run
|
||||
assert run["files"] == 1 and run["missing_files"] == 0
|
||||
path = folder / run["name"]
|
||||
assert path.is_file() and not list(folder.glob(".*"))
|
||||
|
||||
# No credential travels in a backup.
|
||||
with tarfile.open(path) as tar:
|
||||
settings_rows = tar.extractfile("tables/settings.jsonl").read().decode()
|
||||
names = tar.getnames()
|
||||
assert "hunter2" not in settings_rows and '"secret_key"' not in settings_rows
|
||||
assert '"backup_folder"' in settings_rows
|
||||
assert "tables/password_resets.jsonl" not in names
|
||||
|
||||
# A different instance: the database emptied and the attachments gone.
|
||||
async with session_scope() as fresh:
|
||||
await fresh.execute(text(f"TRUNCATE {_TABLES}, device_tokens RESTART IDENTITY CASCADE"))
|
||||
await fresh.commit()
|
||||
for f in Config.media_root().rglob("*"):
|
||||
if f.is_file():
|
||||
f.unlink()
|
||||
|
||||
counts = await restore(path)
|
||||
assert counts["files"] == 1 and counts["notes"] == 1
|
||||
|
||||
async with session_scope() as fresh:
|
||||
back = await fresh.get(Note, uuid.UUID(note["id"]))
|
||||
assert back.body == "buy milk #grocery"
|
||||
tags = (await fresh.execute(select(NoteLabel).where(NoteLabel.note_id == back.id))).scalars().all()
|
||||
assert len(tags) == 1
|
||||
att = await fresh.scalar(select(NoteAttachment).where(NoteAttachment.note_id == back.id))
|
||||
assert (Config.media_root() / att.path).read_bytes() == b"photo bytes"
|
||||
|
||||
# And it refuses an instance that already has accounts.
|
||||
with pytest.raises(SystemExit):
|
||||
await restore(path)
|
||||
await _backup_settings_off()
|
||||
|
||||
|
||||
async def test_back_up_now_says_what_is_wrong_with_the_destination(app_client, db, tmp_path):
|
||||
from inkwell import backup_job
|
||||
|
||||
await _backup_settings_off()
|
||||
await _admin_with_invite(app_client)
|
||||
await app_client.patch("/api/settings", json={"backup_folder": str(tmp_path / "not-mounted")})
|
||||
|
||||
tested = await app_client.post("/api/backups/test")
|
||||
assert tested.status_code == 400
|
||||
assert "isn't there" in (await tested.get_json())["error"]
|
||||
|
||||
assert (await app_client.post("/api/backups/run")).status_code == 202
|
||||
await backup_job.drain()
|
||||
status = await (await app_client.get("/api/backups")).get_json()
|
||||
assert status["runs"][0]["ok"] is False
|
||||
assert "isn't there" in status["runs"][0]["error"]
|
||||
await _backup_settings_off()
|
||||
|
||||
Reference in New Issue
Block a user