Applications auto-hébergées¶
Ces applications se sauvegardent en deux morceaux : une base de données et une arborescence de fichiers. Les deux morceaux se testent séparément, avec deux plans distincts plutôt qu'un seul — un plan qui échoue vous dit alors lequel des deux est en cause, sans lecture de journal.
Voir comment adapter le bloc fetch et
comment trouver les seuils.
Les mots de passe des exemples sont ceux des conteneurs jetables créés pour
l'exécution.
PocketBase¶
PocketBase est le cas qui explique le mieux ce que fait RestoreProof. La même sauvegarde — un ZIP produit par PocketBase lui-même — donne deux plans, et ils ne prouvent pas la même chose.
Plan 1 : le fichier est sain¶
Ce plan décompresse le ZIP et ouvre pb_data/data.db avec SQLite, page par
page.
{
"steps": [
{
"type": "fetch",
"name": "Récupérer la sauvegarde PocketBase",
"config": {
"source": "local",
"path": "/backups/pocketbase/files",
"file_pattern": "pb_*.zip",
"selection_strategy": "latest_modified",
"max_age": "3h",
"output": "pocketbase.zip"
}
},
{
"type": "unpack",
"name": "Extraire l'archive",
"config": { "format": "zip", "input": "pocketbase.zip", "output": "pb_data" }
}
],
"probes": [
{
"name": "base_intacte",
"type": "sqlite",
"timeout": 120,
"config": {
"RESTOREPROOF_SQLITE_PATH": "pb_data/data.db",
"RESTOREPROOF_SQLITE_INTEGRITY": "full"
}
},
{
"name": "articles",
"type": "sqlite",
"timeout": 60,
"config": {
"RESTOREPROOF_SQLITE_PATH": "pb_data/data.db",
"RESTOREPROOF_SQLITE_INTEGRITY": "off",
"RESTOREPROOF_SQLITE_TARGET": "articles",
"RESTOREPROOF_SQLITE_ASSERT": "eq",
"RESTOREPROOF_SQLITE_EXPECTED_ROWS": "100"
}
},
{
"name": "superusers",
"type": "sqlite",
"timeout": 60,
"config": {
"RESTOREPROOF_SQLITE_PATH": "pb_data/data.db",
"RESTOREPROOF_SQLITE_INTEGRITY": "off",
"RESTOREPROOF_SQLITE_TARGET": "_superusers",
"RESTOREPROOF_SQLITE_ASSERT": "gte",
"RESTOREPROOF_SQLITE_EXPECTED_ROWS": "1"
}
}
]
}
Ce que ça prouve :
base_intacte—integrity_checken modefulllit chaque page du fichier. C'est ce qu'unsha256sumne dit pas : une empreinte prouve que le fichier n'a pas changé depuis la copie, pas qu'il était valide au moment de la copie. Une sauvegarde faite autarsur une base vivante rate ce contrôle. Les modes possibles sontquick,fulletoff;offsert aux sondes suivantes, qui n'ont pas besoin de refaire la vérification.articles— la table compte exactement 100 lignes.superusers— il reste au moins un compte d'administration.
La sonde sqlite travaille sur une copie du fichier : SQLite a besoin d'écrire
pour rejouer le journal -wal à l'ouverture, et c'est précisément ce qui fait
entrer les dernières transactions d'une base copiée à chaud dans la preuve.
Plan 2 : l'application redémarre dessus¶
Même sauvegarde, mais on démarre un vrai PocketBase sur le dossier restauré et on lui demande ses articles par l'API.
{
"steps": [
{
"type": "fetch",
"name": "Récupérer la sauvegarde PocketBase",
"config": {
"source": "local",
"path": "/backups/pocketbase/files",
"file_pattern": "pb_*.zip",
"selection_strategy": "latest_modified",
"max_age": "3h",
"output": "pocketbase.zip"
}
},
{
"type": "unpack",
"name": "Extraire l'archive",
"config": { "format": "zip", "input": "pocketbase.zip", "output": "pb_data" }
},
{
"type": "start_sandbox",
"name": "Démarrer PocketBase sur les données restaurées",
"config": {
"image": "ghcr.io/muchobien/pocketbase:0.40.2",
"name": "demo-pocketbase-restore",
"volumes": ["${WORKSPACE}/pb_data:/pb_data"],
"healthcheck": {
"cmd": ["wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"],
"interval": "5s",
"timeout": "5s",
"retries": 12
}
}
}
],
"probes": [
{
"name": "articles",
"type": "pocketbase",
"timeout": 60,
"config": {
"RESTOREPROOF_PB_URL": "http://demo-pocketbase-restore:8090",
"RESTOREPROOF_PB_COLLECTION": "articles",
"RESTOREPROOF_PB_ASSERT": "eq",
"RESTOREPROOF_PB_EXPECTED_ROWS": "100",
"RESTOREPROOF_PB_ADMIN_EMAIL": "demo@kolapsis.com",
"RESTOREPROOF_PB_ADMIN_PASSWORD": "env://PB_ADMIN_PASSWORD"
}
}
]
}
Pourquoi les deux¶
Un fichier intact n'est pas une application qui redémarre. PocketBase migre
pb_data au démarrage : un fichier parfaitement sain peut refuser de s'ouvrir
avec une version qui ne correspond pas à celle qui l'a écrit.
D'où l'image épinglée dans le plan 2, ghcr.io/muchobien/pocketbase:0.40.2,
la même version qu'en production. Changer l'une sans l'autre fait échouer le
plan — et c'est le bon comportement, parce que c'est ce qui arriverait le jour
de la vraie restauration.
Le plan 1 seul répond à « mon fichier est-il corrompu ? ». Le plan 2 répond à « mon service redémarre-t-il ? ». Ce sont deux questions, gardez les deux réponses.
Le mot de passe administrateur¶
Compter les enregistrements d'une collection privée demande de s'authentifier.
Le plan porte donc env://PB_ADMIN_PASSWORD, pas le mot de passe : le runner
le lit dans son propre environnement, chez vous, et RestoreProof ne l'enregistre
jamais.
Sur la commande du runner :
Une référence qui ne se résout pas fait échouer la sonde, en nommant la clé de configuration en cause — jamais la valeur, jamais le nom de la variable.
Pour une collection publiquement lisible, laissez les deux champs d'administration vides.
WordPress¶
Deux plans, comme partout : la base et les fichiers.
- La base : le plan MySQL complet est sur
Bases de données → MySQL. Il vérifie les
comptes, les articles publiés,
siteurl/home, et l'absence d'article orphelin. - Les fichiers :
wp-content, ci-dessous.
{
"steps": [
{
"type": "fetch",
"name": "Récupérer wp-content",
"config": {
"source": "local",
"path": "/backups/wordpress/files",
"file_pattern": "wp-content_*.tar.gz",
"selection_strategy": "latest_modified",
"max_age": "3h",
"output": "wp-content.tar.gz"
}
},
{
"type": "unpack",
"name": "Extraire l'archive",
"config": {
"format": "tar.gz",
"input": "wp-content.tar.gz",
"output": "."
}
}
],
"probes": [
{
"name": "fichiers_du_site",
"type": "filesystem-canary",
"timeout": 60,
"config": {
"RESTOREPROOF_CANARY_FILES": "[{\"path\":\"wp-content/index.php\"},{\"path\":\"wp-content/themes\"},{\"path\":\"wp-content/plugins\"}]",
"RESTOREPROOF_CANARY_MIN_FILES": "50",
"RESTOREPROOF_CANARY_MIN_TOTAL_SIZE": "102400"
}
}
]
}
Ce que ça prouve : wp-content a ses trois repères habituels, et
l'arborescence n'a pas fondu. Un site réel dépasse largement 50 fichiers —
relevez le plancher après votre premier essai.
Aller jusqu'au site qui répond¶
Le modèle WordPress Site de l'interface va plus loin que ces deux plans :
il restaure la base, monte wp-content dans un conteneur wordpress, et
interroge l'API REST du site démarré.
{
"name": "wp_rest",
"type": "http",
"timeout": 60,
"config": {
"RESTOREPROOF_HTTP_URL": "http://wp-app:80/wp-json/wp/v2/posts",
"RESTOREPROOF_HTTP_EXPECTED_STATUS": "200",
"RESTOREPROOF_HTTP_EXPECTED_BODY": "\"rendered\"",
"RESTOREPROOF_HTTP_TIMEOUT": "30"
}
}
RESTOREPROOF_HTTP_EXPECTED_BODY est le champ qui compte. Un code 200 sur la
page d'accueil prouve seulement que le conteneur a démarré : une installation
neuve répond 200 elle aussi. Un article qui revient par l'API REST prouve toute
la chaîne — dump, base, application, HTTP.
Gitea¶
La base¶
{
"steps": [
{
"type": "fetch",
"name": "Récupérer le dump Gitea",
"config": {
"source": "local",
"path": "/backups/gitea/postgres",
"file_pattern": "gitea_*.sql.gz",
"selection_strategy": "latest_modified",
"max_age": "3h",
"output": "gitea.sql.gz"
}
},
{
"type": "unpack",
"name": "Décompresser",
"config": { "format": "gzip", "input": "gitea.sql.gz", "output": "gitea.sql" }
},
{
"type": "start_sandbox",
"name": "Démarrer PostgreSQL",
"config": {
"image": "postgres:16-alpine",
"name": "demo-gitea-restore",
"env": { "POSTGRES_DB": "gitea", "POSTGRES_USER": "gitea", "POSTGRES_PASSWORD": "demo-gitea" },
"healthcheck": { "cmd": ["pg_isready", "-U", "gitea"], "interval": "5s", "timeout": "5s", "retries": 12 }
}
},
{
"type": "restore_postgres",
"name": "Restaurer la base",
"config": {
"database": "gitea",
"user": "gitea",
"password": "demo-gitea",
"input": "gitea.sql",
"format": "plain"
}
}
],
"probes": [
{
"name": "comptes",
"type": "postgres",
"timeout": 60,
"config": {
"PGHOST": "demo-gitea-restore",
"PGDATABASE": "gitea",
"PGUSER": "gitea",
"PGPASSWORD": "demo-gitea",
"RESTOREPROOF_PG_TABLE": "user",
"RESTOREPROOF_PG_ASSERT": "gte",
"RESTOREPROOF_PG_EXPECTED_ROWS": "1"
}
},
{
"name": "depots",
"type": "postgres",
"timeout": 60,
"config": {
"PGHOST": "demo-gitea-restore",
"PGDATABASE": "gitea",
"PGUSER": "gitea",
"PGPASSWORD": "demo-gitea",
"RESTOREPROOF_PG_TABLE": "repository",
"RESTOREPROOF_PG_ASSERT": "gte",
"RESTOREPROOF_PG_EXPECTED_ROWS": "1"
}
},
{
"name": "depots_sans_proprietaire",
"type": "postgres",
"timeout": 60,
"config": {
"PGHOST": "demo-gitea-restore",
"PGDATABASE": "gitea",
"PGUSER": "gitea",
"PGPASSWORD": "demo-gitea",
"RESTOREPROOF_PG_TABLE": "repository r LEFT JOIN \"user\" u ON u.id = r.owner_id WHERE u.id IS NULL",
"RESTOREPROOF_PG_ASSERT": "zero"
}
}
]
}
depots_sans_proprietaire est la sonde qui vaut le détour : elle ne compte
rien, elle exige zéro. Un dépôt sans propriétaire est un dépôt que Gitea
n'affichera à personne, alors que les deux tables sont pleines.
Les fichiers¶
Un dépôt Git n'est pas clonable parce qu'il a le bon nom : il lui faut son
HEAD, ses refs et ses objects.
{
"steps": [
{
"type": "fetch",
"name": "Récupérer les dépôts",
"config": {
"source": "local",
"path": "/backups/gitea/files",
"file_pattern": "repositories_*.tar.gz",
"selection_strategy": "latest_modified",
"max_age": "3h",
"output": "repositories.tar.gz"
}
},
{
"type": "unpack",
"name": "Extraire l'archive",
"config": {
"format": "tar.gz",
"input": "repositories.tar.gz",
"output": "."
}
}
],
"probes": [
{
"name": "depot_clonable",
"type": "filesystem-canary",
"timeout": 60,
"config": {
"RESTOREPROOF_CANARY_FILES": "[{\"path\":\"repositories/demo/demo.git/HEAD\"},{\"path\":\"repositories/demo/demo.git/refs\"},{\"path\":\"repositories/demo/demo.git/objects\"}]",
"RESTOREPROOF_CANARY_MIN_FILES": "10",
"RESTOREPROOF_CANARY_MIN_TOTAL_SIZE": "10240"
}
}
]
}
Remplacez demo/demo.git par un de vos dépôts — de préférence celui dont la
perte ferait le plus mal.
Vaultwarden¶
Un gestionnaire de mots de passe restauré à moitié est pire qu'un gestionnaire perdu : il a l'air complet. Les quatre sondes ci-dessous testent les liens, pas seulement les volumes.
{
"steps": [
{
"type": "fetch",
"name": "Récupérer le dump Vaultwarden",
"config": {
"source": "local",
"path": "/backups/vaultwarden/postgres",
"file_pattern": "vaultwarden_*.sql.gz",
"selection_strategy": "latest_modified",
"max_age": "3h",
"output": "vaultwarden.sql.gz"
}
},
{
"type": "unpack",
"name": "Décompresser",
"config": { "format": "gzip", "input": "vaultwarden.sql.gz", "output": "vaultwarden.sql" }
},
{
"type": "start_sandbox",
"name": "Démarrer PostgreSQL",
"config": {
"image": "postgres:16-alpine",
"name": "demo-vaultwarden-restore",
"env": { "POSTGRES_DB": "vaultwarden", "POSTGRES_USER": "vaultwarden", "POSTGRES_PASSWORD": "demo-vaultwarden" },
"healthcheck": { "cmd": ["pg_isready", "-U", "vaultwarden"], "interval": "5s", "timeout": "5s", "retries": 12 }
}
},
{
"type": "restore_postgres",
"name": "Restaurer la base",
"config": {
"database": "vaultwarden",
"user": "vaultwarden",
"password": "demo-vaultwarden",
"input": "vaultwarden.sql",
"format": "plain"
}
}
],
"probes": [
{
"name": "comptes_actifs",
"type": "postgres",
"timeout": 60,
"config": {
"PGHOST": "demo-vaultwarden-restore",
"PGDATABASE": "vaultwarden",
"PGUSER": "vaultwarden",
"PGPASSWORD": "demo-vaultwarden",
"RESTOREPROOF_PG_TABLE": "users WHERE length(password_hash) > 0",
"RESTOREPROOF_PG_ASSERT": "gte",
"RESTOREPROOF_PG_EXPECTED_ROWS": "1"
}
},
{
"name": "coffres_dechiffrables",
"type": "postgres",
"timeout": 60,
"config": {
"PGHOST": "demo-vaultwarden-restore",
"PGDATABASE": "vaultwarden",
"PGUSER": "vaultwarden",
"PGPASSWORD": "demo-vaultwarden",
"RESTOREPROOF_PG_TABLE": "users WHERE length(password_hash) > 0 AND (akey IS NULL OR akey = '' OR private_key IS NULL OR public_key IS NULL)",
"RESTOREPROOF_PG_ASSERT": "zero"
}
},
{
"name": "entrees_lisibles",
"type": "postgres",
"timeout": 60,
"config": {
"PGHOST": "demo-vaultwarden-restore",
"PGDATABASE": "vaultwarden",
"PGUSER": "vaultwarden",
"PGPASSWORD": "demo-vaultwarden",
"RESTOREPROOF_PG_TABLE": "ciphers WHERE data IS NULL OR data = ''",
"RESTOREPROOF_PG_ASSERT": "zero"
}
},
{
"name": "entrees_orphelines",
"type": "postgres",
"timeout": 60,
"config": {
"PGHOST": "demo-vaultwarden-restore",
"PGDATABASE": "vaultwarden",
"PGUSER": "vaultwarden",
"PGPASSWORD": "demo-vaultwarden",
"RESTOREPROOF_PG_TABLE": "ciphers c LEFT JOIN users u ON u.uuid = c.user_uuid WHERE c.user_uuid IS NOT NULL AND u.uuid IS NULL",
"RESTOREPROOF_PG_ASSERT": "zero"
}
}
]
}
Ce que chaque sonde prouve :
comptes_actifs— au moins un compte a gardé son empreinte de mot de passe ;coffres_dechiffrables— aucun compte activé n'a perdu son matériel de chiffrement (akey, clés publique et privée). Sans lui, le compte existe et son coffre est illisible ;entrees_lisibles— aucune entrée vide ;entrees_orphelines— aucune entrée rattachée à un compte disparu.
Côté fichiers, le dossier data contient la clé de signature :
{
"steps": [
{
"type": "fetch",
"name": "Récupérer le dossier de données",
"config": {
"source": "local",
"path": "/backups/vaultwarden/files",
"file_pattern": "data_*.tar.gz",
"selection_strategy": "latest_modified",
"max_age": "3h",
"output": "data.tar.gz"
}
},
{
"type": "unpack",
"name": "Extraire l'archive",
"config": {
"format": "tar.gz",
"input": "data.tar.gz",
"output": "."
}
}
],
"probes": [
{
"name": "cle_de_signature",
"type": "filesystem-canary",
"timeout": 60,
"config": {
"RESTOREPROOF_CANARY_PATH": "data/rsa_key.pem",
"RESTOREPROOF_CANARY_MIN_FILES": "2",
"RESTOREPROOF_CANARY_MIN_TOTAL_SIZE": "2048"
}
}
]
}
Les versions anciennes de Vaultwarden nomment autrement le fichier de clé : si la sonde échoue, regardez ce que contient réellement l'archive et corrigez le chemin.
Docmost¶
- La base : le plan PostgreSQL complet est sur Bases de données → PostgreSQL. Il vérifie que le schéma est complet et qu'il reste un compte.
- Les fichiers : le plan de la
recette fichiers est exactement celui de
Docmost — il cherche le dossier
storage.
Après avoir créé vos espaces et vos pages, remplacez la sonde « au moins un compte » par un compte de pages : c'est la donnée dont la perte se verrait.