Files
budget-tracker/docker-backup.js
T
Nox 154e4eec70 feat: add docker-backup.js — backup automatique volumes Docker via Portainer API
- Backup Vaultwarden (SQLite + config + clés RSA), Vikunja, NocoDB, FreshRSS
- Nettoyage automatique (keepLast N backups par service)
- Compatible sh/ash/bash selon le conteneur
- Cron OpenClaw configuré : chaque dimanche 03h00 Europe/Paris
- Job ID : 9b81b021-6fc2-4ead-bf69-aec7e3551d70
2026-02-23 11:25:54 +00:00

248 lines
9.5 KiB
JavaScript

#!/usr/bin/env node
/**
* docker-backup.js — Nox 🌑
* Backup automatique des volumes Docker critiques via l'API Portainer.
* Exécute un cp dans chaque conteneur cible et notifie via Telegram.
*
* Usage :
* node docker-backup.js → backup de tous les services configurés
* node docker-backup.js vaultwarden → backup d'un seul service
* node docker-backup.js --list → liste les services configurés
*
* Cron OpenClaw : chaque dimanche à 3h00
*/
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const PORTAINER_URL = 'https://192.168.1.150:9443';
const PORTAINER_TOKEN = process.env.PORTAINER_API_KEY;
const ENDPOINT_ID = 2;
// ─── Configuration des services à sauvegarder ────────────────────────────────
// Pour chaque service :
// container : nom exact du conteneur Docker
// dataPath : chemin du volume DANS le conteneur
// backupDir : sous-dossier de backup (dans dataPath)
// keepLast : nombre de backups à conserver (les plus anciens sont supprimés)
// files : (optionnel) liste de fichiers spécifiques à copier (sinon tout dataPath)
// ─────────────────────────────────────────────────────────────────────────────
const SERVICES = [
{
container: 'vaultwarden',
dataPath: '/data',
backupDir: '/data/backups',
keepLast: 4,
files: ['db.sqlite3', 'db.sqlite3-shm', 'db.sqlite3-wal', 'config.json', 'rsa_key.pem', 'rsa_key.pub.pem'],
description: 'Gestionnaire de mots de passe (Bitwarden)',
},
{
container: 'vikunja-vikunja-1',
dataPath: '/app/vikunja/files',
backupDir: '/app/vikunja/files/backups',
keepLast: 3,
files: null, // tout le dossier
description: 'Vikunja — gestionnaire de tâches',
},
{
container: 'nocodb',
dataPath: '/usr/app/data',
backupDir: '/usr/app/data/backups',
keepLast: 3,
files: null,
description: 'NocoDB — base de données no-code',
},
{
container: 'freshrss',
dataPath: '/var/www/FreshRSS/data',
backupDir: '/var/www/FreshRSS/data/backups',
keepLast: 3,
files: null,
description: 'FreshRSS — agrégateur RSS',
},
];
// ─────────────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const TARGET = args.find(a => !a.startsWith('--'));
const LIST_ONLY = args.includes('--list');
// ─── Helpers ─────────────────────────────────────────────────────────────────
async function portainerFetch(path, opts = {}) {
const res = await fetch(PORTAINER_URL + path, {
...opts,
headers: { 'X-API-Key': PORTAINER_TOKEN, 'Content-Type': 'application/json', ...(opts.headers || {}) },
});
return res;
}
async function getContainerId(name) {
const r = await portainerFetch(`/api/endpoints/${ENDPOINT_ID}/docker/containers/json?all=true`);
const containers = await r.json();
const c = containers.find(c => c.Names.some(n => n === '/' + name || n === name));
return c?.Id;
}
async function execInContainer(containerId, cmd) {
const ec = await portainerFetch(`/api/endpoints/${ENDPOINT_ID}/docker/containers/${containerId}/exec`, {
method: 'POST',
body: JSON.stringify({ AttachStdout: true, AttachStderr: true, Cmd: cmd }),
});
const { Id } = await ec.json();
const es = await portainerFetch(`/api/endpoints/${ENDPOINT_ID}/docker/exec/${Id}/start`, {
method: 'POST',
body: JSON.stringify({ Detach: false, Tty: false }),
});
const buf = await es.arrayBuffer();
const bytes = new Uint8Array(buf);
let result = '';
let i = 0;
while (i < bytes.length) {
if (i + 8 > bytes.length) break;
const sz = (bytes[i+4]<<24)|(bytes[i+5]<<16)|(bytes[i+6]<<8)|bytes[i+7];
result += new TextDecoder().decode(bytes.slice(i+8, i+8+sz));
i += 8 + sz;
}
return result.trim();
}
function getTimestamp() {
const now = new Date();
return now.toISOString().replace(/T/, '_').replace(/:/g, '-').split('.')[0];
}
function getDateStr() {
return new Date().toISOString().split('T')[0];
}
// ─── Backup d'un service ──────────────────────────────────────────────────────
async function backupService(service) {
const { container, dataPath, backupDir, keepLast, files, description } = service;
const timestamp = getTimestamp();
const backupPath = `${backupDir}/${timestamp}`;
console.log(`\n📦 [${container}] ${description}`);
console.log(` → Backup vers ${backupPath}`);
// Trouver le conteneur
const containerId = await getContainerId(container);
if (!containerId) {
console.log(` ⚠️ Conteneur "${container}" introuvable — ignoré`);
return { service: container, status: 'skipped', reason: 'container not found' };
}
// Créer le dossier de backup
await execInContainer(containerId, ['mkdir', '-p', backupPath]);
// Copier les fichiers
let copyResult;
if (files && files.length > 0) {
// Copier fichiers spécifiques — essayer sh puis ash
for (const shell of ['sh', 'ash', 'bash']) {
const cmd = [shell, '-c', `cp -p ${files.map(f => `${dataPath}/${f}`).join(' ')} ${backupPath}/ 2>&1`];
copyResult = await execInContainer(containerId, cmd);
if (!copyResult.includes('not found')) break;
}
} else {
// Copier tout le dossier (sauf le dossier backups lui-même)
// Essayer sh puis ash (Alpine/BusyBox)
for (const shell of ['sh', 'ash', 'bash']) {
const cmd = [shell, '-c', `find ${dataPath} -maxdepth 1 -not -name backups -not -path ${dataPath} | xargs -I{} cp -rp {} ${backupPath}/ 2>&1`];
copyResult = await execInContainer(containerId, cmd);
if (!copyResult.includes('not found')) break;
}
}
if (copyResult && copyResult.includes('error')) {
console.log(` ❌ Erreur lors de la copie : ${copyResult}`);
return { service: container, status: 'error', reason: copyResult };
}
// Vérifier le backup
const checkResult = await execInContainer(containerId, ['ls', '-lh', backupPath]);
const fileCount = checkResult.split('\n').filter(l => l.trim() && !l.startsWith('total')).length;
const totalLine = checkResult.split('\n').find(l => l.startsWith('total')) || '';
console.log(`${fileCount} fichier(s) copiés (${totalLine.replace('total ', '')})`);
// Nettoyage : garder seulement les N derniers backups
// Essayer sh, puis ash (Alpine/BusyBox), puis liste manuelle
const shells = ['sh', 'ash', 'bash'];
let listResult = '';
for (const shell of shells) {
const res = await execInContainer(containerId, [shell, '-c', `ls -1t ${backupDir} | tail -n +${keepLast + 1}`]);
if (!res.includes('not found') && !res.includes('No such')) {
listResult = res;
break;
}
}
const toDelete = listResult.split('\n').filter(Boolean);
if (toDelete.length > 0) {
for (const old of toDelete) {
await execInContainer(containerId, ['rm', '-rf', `${backupDir}/${old}`]);
console.log(` 🗑️ Ancien backup supprimé : ${old}`);
}
}
return { service: container, status: 'ok', path: backupPath, files: fileCount };
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
if (LIST_ONLY) {
console.log('\n📋 Services configurés pour le backup :\n');
SERVICES.forEach((s, i) => {
console.log(` ${i + 1}. ${s.container.padEnd(25)} ${s.description}`);
console.log(` Volume: ${s.dataPath} | Garde: ${s.keepLast} backups`);
if (s.files) console.log(` Fichiers: ${s.files.join(', ')}`);
});
return;
}
const targets = TARGET
? SERVICES.filter(s => s.container.toLowerCase().includes(TARGET.toLowerCase()))
: SERVICES;
if (targets.length === 0) {
console.log(`❌ Aucun service trouvé pour "${TARGET}"`);
process.exit(1);
}
console.log(`\n🌑 Nox — Docker Backup — ${getDateStr()}`);
console.log(` ${targets.length} service(s) à sauvegarder\n`);
console.log('─'.repeat(60));
const results = [];
for (const service of targets) {
try {
const result = await backupService(service);
results.push(result);
} catch (err) {
console.log(` ❌ Exception : ${err.message}`);
results.push({ service: service.container, status: 'error', reason: err.message });
}
}
// Résumé
console.log('\n' + '─'.repeat(60));
console.log('📊 Résumé :');
const ok = results.filter(r => r.status === 'ok').length;
const errors = results.filter(r => r.status === 'error').length;
const skipped = results.filter(r => r.status === 'skipped').length;
console.log(`${ok} OK | ❌ ${errors} erreur(s) | ⏭️ ${skipped} ignoré(s)`);
if (errors > 0) {
console.log('\n⚠️ Erreurs :');
results.filter(r => r.status === 'error').forEach(r => {
console.log(` - ${r.service}: ${r.reason}`);
});
process.exit(1);
}
}
main().catch(err => {
console.error('❌ Fatal:', err.message);
process.exit(1);
});