feat: Settings can point backups at an off-site S3 bucket, with a Test destination check
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 27s
CI and images / backend-lint-and-test (push) Successful in 40s
CI and images / integration (push) Failing after 19s
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 → Storage gains "Off-site backup destination": the
address (with its port), bucket, region, access key and secret key of an S3
bucket such as Fabled Archives. The secret key is sealed with the credential
key, never returned by the API, and kept when the field is left blank. Test
destination lists the bucket with the fields as typed and writes nothing, and
names the cause when it fails ("nothing is listening there (check the port)",
"the secret key is wrong", "answered, but not as S3").

services/backup_s3 is a standard-library SigV4 client (put, get, paged list)
ported from Grimoire's; it is held to AWS's published signing examples and
exercised against a local HTTP server. Migration 0118 adds the columns; no
destination is set up until someone saves one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 16:04:57 -05:00
co-authored by Claude Opus 5.5
parent 315399c4e5
commit 4477d7d14f
10 changed files with 1146 additions and 1 deletions
@@ -0,0 +1,198 @@
<!--
Off-site backup destination: an S3 bucket (Fabled Archives or any S3
service) that backups are sent to. The five fields only work together, so
they save as one with an explicit button rather than field by field. The
secret key is write-only: once saved it is never shown again, and leaving the
field blank keeps it. Test destination lists the bucket with what is typed
in the form (saved or not) and writes nothing.
-->
<template>
<MaintenanceTile
icon="mdi-cloud-upload-outline"
title="Off-site backup destination"
blurb="The S3 bucket (Fabled Archives or any S3 service) that backups are sent to."
>
<p class="fc-muted text-body-2 mb-3">
Backups sent here survive losing this server. FabledCurator only adds to the
bucket and never deletes from it, so a key that may add and read objects but
not delete them is enough, and is the safer key to use. Account logins are
never sent.
</p>
<v-alert
v-if="loadError" type="warning" variant="tonal" density="compact" class="mb-3"
>{{ loadError }}</v-alert>
<v-text-field
v-model="form.endpoint" label="Address"
placeholder="http://archives.example:8081" density="compact"
hint="Include the port the S3 service listens on." persistent-hint
class="mb-3" :disabled="busy"
/>
<div class="fc-dest-row mb-3">
<v-text-field
v-model="form.bucket" label="Bucket" density="compact" hide-details
:disabled="busy"
/>
<v-text-field
v-model="form.region" label="Region" density="compact" hide-details
class="fc-dest-region" :disabled="busy"
/>
</div>
<v-text-field
v-model="form.access_key" label="Access key" density="compact" hide-details
autocomplete="off" class="mb-3" :disabled="busy"
/>
<v-text-field
v-model="form.secret_key" label="Secret key" type="password"
autocomplete="new-password" density="compact"
:placeholder="secretSet ? 'Saved. Leave blank to keep it.' : ''"
:persistent-placeholder="secretSet"
:hint="secretSet ? 'A saved secret is never shown again.' : ''" persistent-hint
class="mb-3" :disabled="busy"
/>
<div class="d-flex align-center flex-wrap mb-2" style="gap: 8px;">
<v-btn
size="small" variant="flat" color="accent" rounded="pill"
prepend-icon="mdi-content-save" :loading="saving"
:disabled="busy || !dirty" @click="onSave"
>Save</v-btn>
<v-btn
size="small" variant="tonal" rounded="pill"
prepend-icon="mdi-lan-connect" :loading="testing"
:disabled="busy || !form.endpoint" @click="onTest"
>Test destination</v-btn>
<v-btn
v-if="configured" size="small" variant="text" rounded="pill" color="error"
:disabled="busy" @click="confirmRemove = true"
>Remove</v-btn>
<span v-if="savedNote" class="text-caption text-success">{{ savedNote }}</span>
</div>
<v-alert
v-if="testResult" :type="testResult.ok ? 'success' : 'error'"
variant="tonal" density="compact" class="mb-1"
>{{ testResult.message }}</v-alert>
<p v-if="!configured && !testResult" class="fc-muted text-caption mb-0">
No destination is set up, so nothing is sent off this server yet.
</p>
<v-dialog v-model="confirmRemove" max-width="440">
<v-card>
<v-card-title class="text-h6">Remove the destination?</v-card-title>
<v-card-text class="text-body-2">
FabledCurator forgets the address and both keys and stops sending
backups there. What was already sent stays in the bucket.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="confirmRemove = false">Cancel</v-btn>
<v-btn color="error" variant="flat" :loading="saving" @click="onRemove">
Remove
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</MaintenanceTile>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useBackupStore } from '../../stores/backup.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
const store = useBackupStore()
const FIELDS = ['endpoint', 'bucket', 'access_key', 'region']
const form = reactive({ endpoint: '', bucket: '', access_key: '', region: 'us-east-1', secret_key: '' })
const saved = ref(null)
const loadError = ref('')
const saving = ref(false)
const testing = ref(false)
const testResult = ref(null)
const savedNote = ref('')
const confirmRemove = ref(false)
const busy = computed(() => saving.value || testing.value)
const secretSet = computed(() => Boolean(saved.value?.secret_set))
const configured = computed(() => Boolean(saved.value?.configured))
const dirty = computed(() => (
!saved.value
|| form.secret_key.trim() !== ''
|| FIELDS.some((f) => (form[f] || '').trim() !== (saved.value[f] || ''))
))
function apply(dest) {
saved.value = dest
for (const f of FIELDS) form[f] = dest[f] || (f === 'region' ? 'us-east-1' : '')
form.secret_key = ''
}
function fields() {
const out = {}
for (const f of FIELDS) out[f] = form[f]
if (form.secret_key.trim()) out.secret_key = form.secret_key
return out
}
function messageOf(e) {
return e?.body?.detail || e?.message || 'The request failed.'
}
onMounted(async () => {
try {
apply(await store.loadDestination())
} catch (e) {
loadError.value = `Couldn't load the destination: ${messageOf(e)}`
}
})
async function onSave() {
saving.value = true
savedNote.value = ''
testResult.value = null
try {
apply(await store.saveDestination(fields()))
savedNote.value = 'Saved.'
} catch (e) {
testResult.value = { ok: false, message: `Not saved: ${messageOf(e)}` }
} finally {
saving.value = false
}
}
async function onTest() {
testing.value = true
savedNote.value = ''
testResult.value = null
try {
testResult.value = await store.testDestination(fields())
} catch (e) {
testResult.value = { ok: false, message: messageOf(e) }
} finally {
testing.value = false
}
}
async function onRemove() {
saving.value = true
try {
apply(await store.removeDestination())
testResult.value = null
savedNote.value = 'Removed.'
confirmRemove.value = false
} catch (e) {
testResult.value = { ok: false, message: `Not removed: ${messageOf(e)}` }
} finally {
saving.value = false
}
}
</script>
<style scoped>
.fc-dest-row { display: flex; gap: 12px; flex-wrap: wrap; }
.fc-dest-row > :first-child { flex: 1 1 200px; }
.fc-dest-region { flex: 0 1 160px; }
</style>
@@ -63,8 +63,11 @@
<section class="fc-section">
<h3 class="fc-section__title">Storage</h3>
<p class="fc-section__hint">Database + image backups and restore.</p>
<p class="fc-section__hint">
Database + image backups and restore, and where backups are sent off this server.
</p>
<div class="fc-tile-stack">
<BackupDestinationCard />
<BackupCard />
</div>
</section>
@@ -93,6 +96,7 @@ import PostAssociationsCard from './PostAssociationsCard.vue'
import GpuAgentCard from './GpuAgentCard.vue'
import AliasTable from './AliasTable.vue'
import BackupCard from './BackupCard.vue'
import BackupDestinationCard from './BackupDestinationCard.vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js'
// Poll queue depths so each task tile's QueueStatusBar shows live pending
+25
View File
@@ -101,9 +101,34 @@ export const useBackupStore = defineStore('backup', () => {
}
}
// Off-site destination (an S3 bucket). The secret key goes up but never
// comes back: the server answers `secret_set` instead.
const destination = ref(null)
async function loadDestination() {
destination.value = await api.get('/api/system/backup/destination')
return destination.value
}
async function saveDestination(fields) {
destination.value = await api.patch('/api/system/backup/destination', { body: fields })
return destination.value
}
async function removeDestination() {
destination.value = await api.delete('/api/system/backup/destination')
return destination.value
}
// Resolves to {ok, message}; an unreachable bucket is a result, not a throw.
async function testDestination(fields) {
return api.post('/api/system/backup/destination/test', { body: fields })
}
return {
dbRuns, imagesRuns, settings, loading, lastError,
loadRuns, triggerBackup, restore, deleteRun, setTag,
loadSettings, patchSettings,
destination, loadDestination, saveDestination, removeDestination, testDestination,
}
})