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
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user