Sauvegarde Home Assistant : configuree, poussee sur deux autres machines, verifiee

Aucune sauvegarde HA n avait jamais ete faite, et git ne la couvrait pas
-- un seul fichier HA y etait versionne. HA declarait pourtant trois
destinations Synology, dont 10.10.10.22 et une troisieme machine en
10.10.10.50 absente de toute la documentation du projet.

Quotidienne 04:45, sept copies, base d historique exclue (20 Mo au lieu
de 4,4 Go), poussee sur les trois machines. La configuration passe par
le websocket car l API REST ne l expose pas.

Verifiee archive en main : .storage, packages, dashboards, secrets et
custom_components presents, base d historique absente comme voulu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
Tallagrand Thierry
2026-09-01 03:13:55 +02:00
parent ccd764beb9
commit 57c696928d
3 changed files with 244 additions and 0 deletions

View File

@@ -2259,3 +2259,65 @@ Le contacteur EDF reste incommandable si internet tombe (Tuya cloud). Un
contacteur local — ou un `localtuya` — est le prochain vrai gain de résilience. contacteur local — ou un `localtuya` — est le prochain vrai gain de résilience.
La sauvegarde (6 Mo d'identité vers Gitea, 6,1 Go d'historique en hebdomadaire La sauvegarde (6 Mo d'identité vers Gitea, 6,1 Go d'historique en hebdomadaire
avec `sqlite3 .backup`) et le secours froid n8n restent à faire. avec `sqlite3 .backup`) et le secours froid n8n restent à faire.
## 34. Session du 01/09/2026 (nuit) — Sauvegarde Home Assistant : elle n'existait pas, elle existe
### Le point de départ
Aucune sauvegarde HA n'avait jamais été faite (§32). Et **non, git ne la
couvrait pas** : le dépôt contient le code du contrôleur, pas Home Assistant.
Un seul fichier HA y était versionné (`dashboards/bilan_energetique.yaml`).
Les helpers, les entités, les paquets, les cartes, les intégrations n'avaient
aucune copie nulle part.
### La trouvaille qui a simplifié le problème
`backup/agents/info` déclare **quatre destinations**, et le croisement avec les
entrées `synology_dsm` de `.storage/core.config_entries` donne leur hôte réel —
le nom seul ne dit pas où chacune écrit :
| agent | nom | hôte |
|---|---|---|
| `backup.local` | local | 10.10.10.17 |
| `synology_dsm.1660NZN396007` | ARCHIVE2 | **10.10.10.22** |
| `synology_dsm.14B0MPN914514` | ARCHIVE | **10.10.10.50** |
| `synology_dsm.20A0SBRVM9PF2` | ARCHIVE3 | 10.10.10.17 ⚠️ à ne PAS utiliser |
Home Assistant sait donc **pousser lui-même ses sauvegardes vers deux autres
machines**. Aucun script de recopie, aucune tâche planifiée, aucun montage NFS
supplémentaire : il n'y avait qu'à le configurer. (Troisième machine 10.10.10.50
découverte au passage — elle n'apparaissait dans aucun document du projet.)
### Configuration appliquée (`tools/ha-backup-config.mjs`, rejouable)
La configuration de sauvegarde **n'est pas exposée par l'API REST** : elle vit
dans `.storage/backup` et ne se règle que par l'interface ou le **websocket**.
Le script passe par le websocket, ce qui la rend reproductible et relisible.
- **Destinations** : local + 10.10.10.22 + 10.10.10.50. ARCHIVE3 (10.10.10.17)
volontairement exclu : une sauvegarde qui ne survit pas à la panne qu'elle
couvre ne protège rien.
- **Base de données EXCLUE.** Ce qu'il faut restaurer vite, c'est l'identité du
système — 20 Mo compressés. L'historique pèse 4,4 Go, change en permanence et
n'a aucune valeur d'urgence : il se sauvegarde à part, plus rarement, et avec
les précautions SQLite (mode WAL, jamais un `cp` à chaud).
- **Quotidienne à 04:45**, rétention **7 copies**.
- **Sans mot de passe**, choix assumé : ces archives restent sur le réseau local
du propriétaire, et une sauvegarde restaurable vaut mieux qu'une sauvegarde
chiffrée dont la clé est perdue le jour où elle sert.
### Vérifiée, pas seulement déclenchée
Première archive : `Automatic backup 2026.8.1` (a9d35ac9), 01/09 03:11,
**20,3 Mo, présente sur les trois machines**. Contenu contrôlé archive en main :
`.storage` 39 fichiers · `packages` 18 · `dashboards` 30 ·
`custom_components` 2 283 · `configuration.yaml`, `secrets.yaml`,
`automations.yaml`, `scripts.yaml` · et **zéro occurrence** de
`home-assistant_v2.db`, comme voulu.
`tools/ha-backup-list.mjs` affiche pour chaque sauvegarde les hôtes où elle est
réellement présente — c'est cette colonne-là qui compte.
### Ce que cette sauvegarde ne couvre PAS
1. **n8n.** Ni ses workflows (déjà en git, donc couverts autrement), ni sa base
`database.sqlite` (1,69 Go), ni surtout **la clé de chiffrement de 56 octets**
(§32) sans laquelle aucun credential n'est récupérable. C'est le prochain
trou à boucher, et le plus petit.
2. **L'historique HA** (4,4 Go) : perdu en cas de restauration. Conséquence
réelle pour le chantier du bilan énergétique, qui s'appuie sur les
statistiques long terme. À traiter séparément, en hebdomadaire.

134
tools/ha-backup-config.mjs Normal file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* Configure la sauvegarde automatique de Home Assistant (installation Core /
* Container, HA 2026.8.1 — pas de Supervisor ici).
*
* POURQUOI CE SCRIPT PLUTÔT QUE L'INTERFACE
* La configuration de sauvegarde n'est PAS exposée par l'API REST : elle vit
* dans `.storage/backup` et ne se règle que par l'interface ou par le
* websocket. Passer par le websocket la rend reproductible, versionnée, et
* relisible dans six mois — ce qu'un clic dans l'interface n'est pas.
*
* CE QUI EST RÉGLÉ, ET POURQUOI
* - Destinations : les agents Synology **ARCHIVE2 (10.10.10.22)** et
* **ARCHIVE (10.10.10.50)**, plus une copie locale. Le NAS principal
* (ARCHIVE3, 10.10.10.17) est délibérément EXCLU comme destination : une
* sauvegarde qui ne survit pas à la panne qu'elle couvre ne sert à rien.
* - Base de données EXCLUE. Ce qu'il faut pouvoir restaurer vite, c'est
* l'identité du système (entités, helpers, cartes, paquets, intégrations) :
* environ 60 Mo. L'historique, lui, pèse 4,4 Go, change en permanence, et
* n'a aucune valeur d'urgence — il se sauvegarde séparément, plus rarement,
* et avec les précautions SQLite (mode WAL : jamais un `cp` à chaud).
* - Rétention 7 copies : une semaine pour s'apercevoir d'une bêtise.
* - Pas de mot de passe. Choix assumé : ces archives restent sur le réseau
* local du propriétaire, et une sauvegarde restaurable vaut mieux qu'une
* sauvegarde chiffrée dont la clé est perdue le jour où elle sert.
*
* Usage :
* node tools/ha-backup-config.mjs --dry-run (affiche avant/après, n'écrit rien)
* node tools/ha-backup-config.mjs (applique)
* node tools/ha-backup-config.mjs --run-now (applique puis déclenche une sauvegarde)
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123';
const ARGS = new Set(process.argv.slice(2));
const DRY = ARGS.has('--dry-run');
const RUN_NOW = ARGS.has('--run-now');
function loadToken() {
if (process.env.HA_TOKEN) return process.env.HA_TOKEN;
const m = fs.readFileSync(path.join(ROOT, '.env'), 'utf8').match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/m);
if (!m) { console.error('HA_TOKEN absent de .env'); process.exit(1); }
return m[1].replace(/^["']|["']$/g, '');
}
// Agents de destination. Identifiants relevés via `backup/agents/info` et
// croisés avec les entrées `synology_dsm` de `.storage/core.config_entries`
// pour connaître l'hôte réel de chacun — le nom seul ne dit pas où il écrit.
const AGENTS = {
local: 'backup.local', // 10.10.10.17, restauration rapide
archive2: 'synology_dsm.1660NZN396007', // 10.10.10.22 — second Synology
archive: 'synology_dsm.14B0MPN914514', // 10.10.10.50 — troisième machine
// 'synology_dsm.20A0SBRVM9PF2' = ARCHIVE3 = 10.10.10.17 : NE PAS UTILISER
// comme destination, c'est la machine dont on se protège.
};
const DESIRED = {
create_backup: {
agent_ids: [AGENTS.local, AGENTS.archive2, AGENTS.archive],
include_database: false,
include_folders: null,
include_all_addons: false,
include_addons: null,
name: null,
password: null,
},
retention: { copies: 7, days: null },
schedule: { recurrence: 'daily', time: '04:45:00', days: [] },
};
const token = loadToken();
const ws = new WebSocket(HA_URL.replace(/^http/, 'ws') + '/api/websocket');
let seq = 1;
const pending = new Map();
function call(msg) {
return new Promise((resolve, reject) => {
const id = seq++;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, ...msg }));
});
}
ws.onmessage = async (ev) => {
const m = JSON.parse(ev.data);
if (m.type === 'auth_required') return ws.send(JSON.stringify({ type: 'auth', access_token: token }));
if (m.type === 'auth_invalid') { console.error('jeton HA refusé'); process.exit(1); }
if (m.type === 'auth_ok') return main().catch(e => { console.error(e.message); process.exit(1); });
if (m.type === 'result') {
const p = pending.get(m.id); pending.delete(m.id);
if (!p) return;
m.success ? p.resolve(m.result) : p.reject(new Error(JSON.stringify(m.error)));
}
};
ws.onerror = () => { console.error(`websocket injoignable sur ${HA_URL}`); process.exit(1); };
async function main() {
const before = (await call({ type: 'backup/config/info' })).config;
console.log('=== AVANT ===');
console.log(` configurée : ${before.automatic_backups_configured}`);
console.log(` récurrence : ${before.schedule.recurrence} à ${before.schedule.time ?? '—'}`);
console.log(` destinations : ${before.create_backup.agent_ids.join(', ') || '— aucune —'}`);
console.log(` base incluse : ${before.create_backup.include_database}`);
console.log(` rétention : ${before.retention.copies ?? '—'} copies`);
console.log('\n=== APRÈS (souhaité) ===');
console.log(` récurrence : ${DESIRED.schedule.recurrence} à ${DESIRED.schedule.time}`);
console.log(' destinations :');
console.log(` ${AGENTS.local} → 10.10.10.17 (copie locale, restauration rapide)`);
console.log(` ${AGENTS.archive2} → 10.10.10.22 (second Synology)`);
console.log(` ${AGENTS.archive} → 10.10.10.50 (troisième machine)`);
console.log(` base incluse : ${DESIRED.create_backup.include_database} (les 4,4 Go d'historique se sauvegardent à part)`);
console.log(` rétention : ${DESIRED.retention.copies} copies`);
if (DRY) { console.log('\n[DRY-RUN] aucune écriture.'); ws.close(); process.exit(0); }
await call({ type: 'backup/config/update', ...DESIRED });
const after = (await call({ type: 'backup/config/info' })).config;
console.log(`\n✅ appliqué — configurée=${after.automatic_backups_configured}, ` +
`prochaine=${after.next_automatic_backup ?? '?'}`);
if (RUN_NOW) {
console.log('\nDéclenchement d\'une sauvegarde de vérification…');
await call({ type: 'backup/generate_with_automatic_settings' });
console.log(' demandée. Suivre avec: node tools/ha-backup-list.mjs');
}
ws.close(); process.exit(0);
}
setTimeout(() => { console.error('délai dépassé'); process.exit(1); }, 60000);

48
tools/ha-backup-list.mjs Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env node
// Liste les sauvegardes Home Assistant et, pour chacune, les destinations où
// elle est RÉELLEMENT présente. C'est cette dernière colonne qui compte : une
// sauvegarde qui n'existe que sur la machine dont on se protège ne protège rien.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123';
const token = process.env.HA_TOKEN ||
fs.readFileSync(path.join(ROOT, '.env'), 'utf8').match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/m)[1].replace(/^["']|["']$/g, '');
const HOST = {
'backup.local': '10.10.10.17 (local)',
'synology_dsm.1660NZN396007': '10.10.10.22',
'synology_dsm.14B0MPN914514': '10.10.10.50',
'synology_dsm.20A0SBRVM9PF2': '10.10.10.17',
};
const ws = new WebSocket(HA_URL.replace(/^http/, 'ws') + '/api/websocket');
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.type === 'auth_required') return ws.send(JSON.stringify({ type: 'auth', access_token: token }));
if (m.type === 'auth_ok') return ws.send(JSON.stringify({ id: 1, type: 'backup/info' }));
if (m.type === 'result') {
const r = m.result || {};
if (r.backups?.length) {
for (const b of r.backups) {
const mo = (b.agents ? Object.entries(b.agents) : []);
console.log(`\n${b.name} (${b.backup_id})`);
console.log(` créée : ${new Date(b.date).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })}`);
console.log(` base incluse : ${b.database_included} automatique : ${b.with_automatic_settings}`);
for (const [agent, info] of mo) {
const mb = info.size ? (info.size / 1048576).toFixed(1) + ' Mo' : '?';
console.log(` → ${(HOST[agent] || agent).padEnd(22)} ${mb.padStart(9)}`);
}
}
} else {
console.log('Aucune sauvegarde.');
}
if (r.next_automatic_backup) {
console.log(`\nProchaine automatique : ${new Date(r.next_automatic_backup).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })}`);
}
ws.close(); process.exit(0);
}
};
setTimeout(() => { console.error('délai dépassé'); process.exit(1); }, 30000);