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