Aller au contenu

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_intacteintegrity_check en mode full lit chaque page du fichier. C'est ce qu'un sha256sum ne 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 au tar sur une base vivante rate ce contrôle. Les modes possibles sont quick, full et off ; off sert 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 :

-e PB_ADMIN_PASSWORD=votre-mot-de-passe

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.