feat: restore a new install from the off-site bucket, database first, then every file
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 1s
CI and images / extension-test (push) Successful in 17s
CI and images / frontend-build (push) Successful in 19s
CI and images / backend-lint-and-test (push) Successful in 45s
CI and images / integration (push) Failing after 2m26s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped

Settings → Maintenance → Backups gains "Restore from the bucket": pick one of
the bucket's dated dumps, type its confirmation, and FabledCurator saves the
current database to the bucket, replaces it with the dump, migrates it to this
version, then fetches every image and attachment back to the path its row
records. Each file is checked against its sha256 on arrival and moved into
place only then; a file already on disk is left alone, so "Fetch missing
files" repairs a library that lost files without touching the database.

- services/backup_restore.py: list/find/fetch dumps, migrate, keep this
  install's destination (the dump's sealed secret can't be opened here),
  library_files work list, restore_file (one fetch per sha, copies for
  duplicate paths, refuses paths outside /images).
- restore_offsite_task: the database phase, then 45-minute file chunks with a
  heartbeat; a failed load leaves an empty migrated database and says why.
  The dump's own run row is marked ok, other runs then going "Interrupted".
- backup_s3.get_file: sha/size check on arrival, ObjectMissing for a 404,
  download pacing.
- backup_service: every dump leaves the credential table's rows out;
  restore_db reports pg_restore's last line, stops at the first error, ends
  other connections and waits at most 60 s for locks.
- Backups and orphan repair wait while a restore runs; the stall sweep judges
  restores by heartbeat; restore runs are pruned to 20.
- CLI: python -m backend.app.scripts.restore list|database|files.
- README: backup and restore section; logins are in no backup.
- Tests: a round trip through an in-process S3 bucket (tests/fake_s3.py) with
  real pg_dump/pg_restore, plus file, run, API and get_file cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 14:34:47 -05:00
co-authored by Claude Opus 5.5
parent d465198c4a
commit 375e7e2eda
19 changed files with 1619 additions and 49 deletions
@@ -89,6 +89,7 @@ const kindLabel = computed(() => ({
artist: 'artist',
tag: 'tag',
'images-selection': 'image selection',
offsite: 'from the off-site bucket',
}[props.kind] || props.kind))
const alertType = computed(
() => props.action === 'restore' ? 'warning' : 'error',
+163 -2
View File
@@ -96,6 +96,87 @@
A run is going: {{ runningSent.toLocaleString() }} files sent so far.
</span>
<BackupRunsTable :runs="store.mediaRuns" />
<v-divider class="my-4" />
<!-- Off-site: restoring this install from the bucket -->
<h3 class="fc-section-title">Restore from the bucket</h3>
<p class="fc-muted text-body-2 mb-2">
Replace this install's database with one of the bucket's dumps, then
fetch every image and attachment back to where it belongs, each
checked against its fingerprint as it arrives. The database being
replaced is saved to the bucket first, so a restore can be undone.
Account logins aren't in the backup: sign in to each again afterwards.
</p>
<p class="fc-muted text-body-2 mb-2">
To repair a library that lost files, fetch only what's missing from
disk; the database stays as it is.
</p>
<div class="fc-settings-row mb-3">
<v-btn
color="warning" variant="tonal" rounded="pill"
prepend-icon="mdi-database-arrow-down"
:disabled="anyGoing" :loading="dumpsLoading"
@click="openDumps"
>Choose a dump…</v-btn>
<v-btn
variant="tonal" rounded="pill"
prepend-icon="mdi-cloud-download-outline"
:disabled="anyGoing" :loading="fetchTriggering"
@click="onFetchMissing"
>Fetch missing files</v-btn>
</div>
<v-alert
v-if="restoreDispatched || runningRestore"
type="info" variant="tonal" density="compact" class="mb-3"
>
<template v-if="runningRestore?.manifest?.phase === 'files'">
Fetching files: {{ restoreLine(runningRestore.manifest) }}.
</template>
<template v-else>
Restoring the database. While it loads, this page may stop answering
for a few minutes; it comes back on its own.
</template>
</v-alert>
<BackupRunsTable :runs="store.restoreRuns" empty-text="No restores yet." />
<v-dialog v-model="dumpsOpen" max-width="560">
<v-card>
<v-card-title>Dumps in {{ offsite.bucket }}</v-card-title>
<v-card-text>
<v-alert
v-if="dumpsError" type="warning" variant="tonal" density="compact" class="mb-3"
>{{ dumpsError }}</v-alert>
<v-table v-else density="compact">
<thead>
<tr><th>Taken</th><th class="text-right">Size</th><th /></tr>
</thead>
<tbody>
<tr v-for="d in dumps" :key="d.key">
<td :title="d.key">{{ dumpWhen(d.ts) }}</td>
<td class="text-right" style="font-variant-numeric: tabular-nums;">
{{ humanBytes(d.size_bytes) }}
</td>
<td class="text-right">
<v-btn size="small" variant="text" color="warning" @click="onPickDump(d)">
Restore…
</v-btn>
</td>
</tr>
<tr v-if="!dumps.length">
<td colspan="3" class="text-center fc-muted py-4">
The bucket holds no database dumps yet.
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dumpsOpen = false">Close</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<template v-else>
@@ -132,18 +213,20 @@
:kind="confirmKind"
:run-id="confirmRunId"
:description="confirmDescription"
:expected-token-override="confirmToken"
@confirm="onConfirmSubmit"
/>
</MaintenanceTile>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useBackupStore } from '../../stores/backup.js'
import BackupConfirmModal from '../modal/DestructiveConfirmModal.vue'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import { humanBytes } from '../../utils/bytes.js'
import { formatDateTime } from '../../utils/date.js'
import BackupRunsTable from './BackupRunsTable.vue'
const store = useBackupStore()
@@ -177,6 +260,79 @@ const confirmAction = ref('restore')
const confirmKind = ref('db')
const confirmRunId = ref(0)
const confirmDescription = ref('')
const confirmToken = ref('')
// Restoring from the bucket.
const runningRestore = computed(() => store.restoreRuns.find((r) => r.status === 'restoring'))
const anyGoing = computed(() => Boolean(
runningRestore.value || mediaRunning.value
|| store.dbRuns.some((r) => r.status === 'running'),
))
const restoreDispatched = ref(false)
const dumpsOpen = ref(false)
const dumpsLoading = ref(false)
const dumpsError = ref('')
const dumps = ref([])
const pickedDump = ref(null)
const fetchTriggering = ref(false)
// The notice that a restore was asked for stays up until its run is over.
watch(runningRestore, (now, before) => {
if (before && !now) restoreDispatched.value = false
})
function restoreLine(m) {
const n = (k) => (m?.[k] || 0).toLocaleString()
return `${n('restored')} restored, ${n('copied')} copied from a duplicate, `
+ `${n('present')} already there, ${n('missing')} not in the bucket, ${n('failed')} failed`
}
// 20261107T134258Z → the reader's local date and time.
function dumpWhen(ts) {
const iso = ts.replace(
/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, '$1-$2-$3T$4:$5:$6Z',
)
return formatDateTime(iso)
}
async function openDumps() {
dumpsLoading.value = true
dumpsError.value = ''
try {
dumps.value = await store.listOffsiteDumps()
} catch (e) {
dumps.value = []
dumpsError.value = e.message
} finally {
dumpsLoading.value = false
dumpsOpen.value = true
}
}
function onPickDump(d) {
pickedDump.value = d
dumpsOpen.value = false
confirmAction.value = 'restore'
confirmKind.value = 'offsite'
confirmRunId.value = ''
confirmToken.value = d.confirm
confirmDescription.value = (
`Replaces this install's database with the dump taken ${dumpWhen(d.ts)}, `
+ 'then fetches the library\'s files from the bucket.'
)
confirmOpen.value = true
}
async function onFetchMissing() {
fetchTriggering.value = true
try {
await store.fetchMissingFiles()
pollOnce()
} catch {
// store.lastError is shown above.
} finally {
fetchTriggering.value = false
}
}
let pollId = null
let polls = 0
@@ -188,6 +344,7 @@ function pollOnce() {
store.loadRuns('db')
if (offsite.value) {
store.loadRuns('media')
store.loadRuns('restore')
if (polls++ % COVERAGE_EVERY === 0) store.loadMediaCoverage()
} else {
store.loadRuns('images')
@@ -223,6 +380,7 @@ function _openConfirm(action, run) {
confirmAction.value = action
confirmKind.value = run.kind
confirmRunId.value = run.id
confirmToken.value = ''
confirmDescription.value = (
action === 'restore'
? `Source artifact: ${run.kind === 'db' ? run.sql_path : run.tar_path}`
@@ -246,7 +404,10 @@ async function onTag(run) {
async function onConfirmSubmit(token) {
try {
if (confirmAction.value === 'restore') {
if (confirmKind.value === 'offsite') {
await store.restoreFromOffsite(pickedDump.value.key, token)
restoreDispatched.value = true
} else if (confirmAction.value === 'restore') {
await store.restore(confirmRunId.value, token)
} else {
await store.deleteRun(confirmRunId.value, token)
@@ -58,7 +58,7 @@
</td>
</tr>
<tr v-if="!runs.length">
<td colspan="7" class="text-center fc-muted py-4">No backups yet.</td>
<td colspan="7" class="text-center fc-muted py-4">{{ emptyText }}</td>
</tr>
</tbody>
</v-table>
@@ -68,7 +68,10 @@
import { formatRelative as fmtRelative } from '../../utils/date.js'
import KebabMenu from '../common/KebabMenu.vue'
defineProps({ runs: { type: Array, default: () => [] } })
defineProps({
runs: { type: Array, default: () => [] },
emptyText: { type: String, default: 'No backups yet.' },
})
defineEmits(['restore', 'delete', 'tag'])
function statusIcon(s) {
+34 -3
View File
@@ -9,10 +9,13 @@ export const useBackupStore = defineStore('backup', () => {
const dbRuns = ref([])
const imagesRuns = ref([])
const mediaRuns = ref([])
const restoreRuns = ref([])
const settings = ref(null)
const loading = ref({ dbRuns: false, imagesRuns: false, mediaRuns: false, settings: false })
const loading = ref({
dbRuns: false, imagesRuns: false, mediaRuns: false, restoreRuns: false, settings: false,
})
const lastError = ref(null)
const RUNS = { db: dbRuns, images: imagesRuns, media: mediaRuns }
const RUNS = { db: dbRuns, images: imagesRuns, media: mediaRuns, restore: restoreRuns }
async function loadRuns(kind) {
const targetRef = RUNS[kind]
@@ -139,8 +142,36 @@ export const useBackupStore = defineStore('backup', () => {
}
}
// Restoring from the bucket. Dumps arrive newest first, each with the
// `confirm` text a restore of it asks for.
async function listOffsiteDumps() {
const body = await api.get('/api/system/backup/offsite/dumps')
return body.dumps || []
}
async function restoreFromOffsite(key, confirm) {
lastError.value = null
try {
await api.post('/api/system/backup/offsite/restore', { body: { key, confirm } })
} catch (e) {
lastError.value = e.message
throw e
}
}
async function fetchMissingFiles() {
lastError.value = null
try {
await api.post('/api/system/backup/offsite/fetch-files', { body: {} })
} catch (e) {
lastError.value = e.message
throw e
}
}
return {
dbRuns, imagesRuns, mediaRuns, mediaCoverage, loadMediaCoverage,
dbRuns, imagesRuns, mediaRuns, restoreRuns, mediaCoverage, loadMediaCoverage,
listOffsiteDumps, restoreFromOffsite, fetchMissingFiles,
settings, loading, lastError,
loadRuns, triggerBackup, restore, deleteRun, setTag,
loadSettings, patchSettings,