Stack-level backup: Nextcloud WebDAV + Docker volume backup via SSH
- backup container: builds from Dockerfile (adds openssh-client, curl, jq) fetches Nextcloud creds from Settings API at runtime, pg_dumps all DBs and tars Docker volumes on each app LXC via SSH, uploads to Nextcloud WebDAV, writes runs.ndjson log, heartbeats Uptime Kuma - updater: gains docker-cli, Docker socket mount, /backup/runs, /backup/status, /backup/trigger endpoints; reads runs.ndjson for status - kuma_data mounted read-only into backup container for local backup - .env.example updated with SETTINGS_URL, SETTINGS_SECRET, PG_SUPERPASS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cc5fc99242
commit
f46fe07ca8
6 changed files with 258 additions and 39 deletions
|
|
@ -2,6 +2,7 @@ import Fastify from 'fastify'
|
|||
import crypto from 'crypto'
|
||||
import { exec, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
|
|
@ -37,6 +38,8 @@ const FORGEJO_ORG = process.env.FORGEJO_ORG || 'hotel-manage-stack'
|
|||
const FORGEJO_TOKEN = process.env.FORGEJO_TOKEN || ''
|
||||
const AUTH_URL = process.env.AUTH_URL || 'http://10.10.10.101:3001'
|
||||
const CENTRAL_AUTH_SECRET = process.env.CENTRAL_AUTH_SECRET || ''
|
||||
const BACKUP_CONTAINER = process.env.BACKUP_CONTAINER || 'management-backup-1'
|
||||
const RUNS_FILE = '/backups/runs.ndjson'
|
||||
|
||||
// Platform services: fixed IPs, never in the user-facing apps table.
|
||||
// 'management' excluded from deploy — can't redeploy the container managing deploys.
|
||||
|
|
@ -297,6 +300,49 @@ app.post('/exec', async (request, reply) => {
|
|||
return result
|
||||
})
|
||||
|
||||
// ── Backup routes ─────────────────────────────────────────────────────────────
|
||||
|
||||
function readBackupRuns() {
|
||||
try {
|
||||
const content = readFileSync(RUNS_FILE, 'utf8').trim()
|
||||
if (!content) return []
|
||||
return content.split('\n')
|
||||
.filter(Boolean)
|
||||
.map(line => { try { return JSON.parse(line) } catch { return null } })
|
||||
.filter(Boolean)
|
||||
.reverse()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/backup/runs', async () => {
|
||||
return readBackupRuns().slice(0, 30)
|
||||
})
|
||||
|
||||
app.get('/backup/status', async () => {
|
||||
const runs = readBackupRuns()
|
||||
if (!runs.length) return { status: 'never', last_run: null }
|
||||
const last = runs[0]
|
||||
return { status: last.status, last_run: last.finished, id: last.id }
|
||||
})
|
||||
|
||||
let backupRunning = false
|
||||
|
||||
app.post('/backup/trigger', async (req, reply) => {
|
||||
if (backupRunning) return reply.status(409).send({ error: 'Backup already running' })
|
||||
|
||||
backupRunning = true
|
||||
reply.status(202).send({ ok: true, message: 'Backup triggered' })
|
||||
|
||||
spawnAsync('docker', ['exec', BACKUP_CONTAINER, 'sh', '/etc/periodic/daily/backup'], {
|
||||
timeout: 600_000,
|
||||
})
|
||||
.then(() => { app.log.info('Manual backup completed') })
|
||||
.catch(e => { app.log.error(`Manual backup failed: ${e.message}`) })
|
||||
.finally(() => { backupRunning = false })
|
||||
})
|
||||
|
||||
app.post('/webhook', { config: { rawBody: true } }, async (request, reply) => {
|
||||
const sig = request.headers['x-hub-signature-256']
|
||||
|| request.headers['x-forgejo-signature']
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue