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
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:
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user