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 exemples ne portent plus de mot de passe pour le conteneur jetable : le
runner le fabrique lui-même à chaque exécution, il n'y a rien à saisir. Voir
le mot de passe du bac à sable.
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. C'est aussi ce que fait le modèle
PocketBase Application de l'interface : il demande l'image à démarrer, le
dossier pb_data dans l'archive, la collection à compter et le compte
d'administration. Une seule chose à corriger dans ce modèle : il propose
latest comme image, remplacez-la par votre version exacte.
{
"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",
"volumes": ["${WORKSPACE}/pb_data:/pb_data"]
}
}
],
"probes": [
{
"name": "articles",
"type": "pocketbase",
"timeout": 60,
"config": {
"RESTOREPROOF_PB_URL": "http://sandbox: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. file://ma_cle et static://ma_cle conviennent aussi, selon l'endroit
où le runner garde ce mot de passe.
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 dans un premier bac à sable, démarre WordPress dans un
second, et interroge l'API REST du site démarré. Le premier start_sandbox
reçoit l'alias sandbox, le second sandbox-2 — c'est l'ordre d'apparition
dans le plan qui décide, pas le rôle du conteneur. Le conteneur WordPress vise
la base par WORDPRESS_DB_HOST: sandbox, et la sonde HTTP vise WordPress par
sandbox-2 :
{
"name": "wp_rest",
"type": "http",
"timeout": 60,
"config": {
"RESTOREPROOF_HTTP_URL": "http://sandbox-2/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",
"env": { "POSTGRES_DB": "gitea" }
}
},
{
"type": "restore_postgres",
"name": "Restaurer la base",
"config": {
"database": "gitea",
"input": "gitea.sql",
"format": "plain"
}
}
],
"probes": [
{
"name": "comptes",
"type": "postgres",
"timeout": 60,
"config": {
"RESTOREPROOF_PG_TABLE": "user",
"RESTOREPROOF_PG_ASSERT": "gte",
"RESTOREPROOF_PG_EXPECTED_ROWS": "1"
}
},
{
"name": "depots",
"type": "postgres",
"timeout": 60,
"config": {
"RESTOREPROOF_PG_TABLE": "repository",
"RESTOREPROOF_PG_ASSERT": "gte",
"RESTOREPROOF_PG_EXPECTED_ROWS": "1"
}
},
{
"name": "depots_sans_proprietaire",
"type": "postgres",
"timeout": 60,
"config": {
"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",
"env": { "POSTGRES_DB": "vaultwarden" }
}
},
{
"type": "restore_postgres",
"name": "Restaurer la base",
"config": {
"database": "vaultwarden",
"input": "vaultwarden.sql",
"format": "plain"
}
}
],
"probes": [
{
"name": "comptes_actifs",
"type": "postgres",
"timeout": 60,
"config": {
"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": {
"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": {
"RESTOREPROOF_PG_TABLE": "ciphers WHERE data IS NULL OR data = ''",
"RESTOREPROOF_PG_ASSERT": "zero"
}
},
{
"name": "entrees_orphelines",
"type": "postgres",
"timeout": 60,
"config": {
"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_FILES": "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.