Aller au contenu

Medusa — export et restauration

Chaque nuit, un CronJob exporte les données KV du coffre OpenBao avec Medusa, les chiffre et les dépose dans le bucket scw-openbao-backup. Cette page décrit comment récupérer un de ces exports et le réinjecter.

Pour savoir quel dispositif couvre quelle perte, voir la vue d'ensemble. Si le cluster est indisponible, allez directement à l'accès d'urgence.

Version courte

# depuis tooling-infra/build/k8s-openbao-restore, sur le cluster de service
kubectl -n openbao-exporter apply -f restore-pod.yaml
kubectl -n openbao-exporter exec -it openbao-restore -- sh
# dans le pod : telecharge et dechiffre le dernier export
python /app/main.py --mount applications --destination /tmp/export.json

# reinjecte, seule etape qui ecrit dans OpenBao
/app/medusa_import.sh -f /tmp/export.json -m applications

main.py récupère la clé de chiffrement, la fait ouvrir par Key Manager et calcule les en-têtes SSE-C du téléchargement. C'est ce qui remplace un curl, impossible ici puisque la clé doit voyager dans la requête.

La suite de la page déroule ces trois commandes, avec le choix de l'export et les vérifications.

Prérequis

Quoi Détail
Cluster service-prod, namespace openbao-exporter
Droits kubectl en écriture sur ce namespace : créer un pod, exécuter dedans
Dépôt tooling-infra cloné sur votre poste, il contient le manifeste du pod
Root token OpenBao nécessaire à l'import

Le dépôt est nécessaire parce que la procédure applique un manifeste qui s'y trouve :

git clone git@gitlab.ademe.fr:ademe-group/infrastructure/tooling-infra.git
cd tooling-infra/build/k8s-openbao-restore

$VAULT_ADDR, $VAULT_TOKEN et $POD sont des variables définies au fil de la procédure, laissez-les telles quelles. Ce qui est en MAJUSCULES sans $ est à remplacer par votre valeur.

Les clés S3 et la clé de chiffrement sont déjà dans le cluster et seront montées dans le pod, vous n'avez aucun identifiant à manipuler. Le root token, en revanche, est indispensable : l'identité de l'exporter est en lecture seule, et un token OIDC APPSVC-* donne accès aux données mais pas à la configuration du coffre.

Vérifiez d'abord que vous êtes sur le bon cluster

OpenBao n'est déployé que sur le cluster de service, et le nom du contexte kubectl varie d'un poste à l'autre.

kubectl -n openbao get statefulset openbao

Vous devez voir le StatefulSet et ses 4 réplicas. Une erreur NotFound signifie que vous n'êtes pas sur le bon cluster, changez de contexte avant de continuer :

kubectl config get-contexts
kubectl config use-context <contexte du cluster de service>

Ce qu'un export contient

Uniquement les données d'un mount, sous forme d'arborescence :

{
  "<projet>": {
    "<env>": {
      "<nom du Deployment>": {
        "MA_VARIABLE": "sa valeur"
      }
    }
  }
}

Le nom du mount n'apparaît pas dans le fichier : il est passé en argument à l'import. Le même fichier peut donc être réinjecté dans n'importe quel mount, y compris le mauvais.

Ce qu'il ne contient pas

Ni les policies, ni l'authentification OIDC, ni les mounts eux-mêmes. Ceux-là sont décrits dans Terraform, module 034.openbao-setup.

C'est un choix : la configuration vit dans git, les données vivent dans le bucket.

Conséquence sur l'ordre des opérations : si le coffre a été reconstruit, il faut terraform apply sur 034.openbao-setup avant de restaurer les données, sinon les mounts n'existent pas.


Quel scénario traitez-vous

Cas A, un secret a été écrasé ou supprimé. Le coffre fonctionne, les mounts existent. Allez directement à l'étape 1.

Cas B, le coffre a été reconstruit. Lancez d'abord terraform apply sur 034.openbao-setup pour recréer les mounts, les policies et l'authentification. Puis revenez à l'étape 1.

Cas C, vous repartez d'un cluster neuf. L'ordre compte, et deux étapes ne sont pas devinables :

  1. cluster reconstruit par Terraform, puis ArgoCD, puis external-secrets
  2. le pod de l'étape 1 tire son image du registre privé avec le secret docker-registry-auth, que External Secrets lit dans Vault, pas dans Secret Manager. Si Vault n'est pas encore revenu, ce secret n'existe pas : créez-le à la main depuis SCW_DOCKER_REGISTRY_TOKEN, qui est dans Secret Manager
  3. OpenBao déployé, puis bao operator init : le root token et les nouvelles parts de descellement sortent de là. Reportez les parts dans les secrets OPENBAO_UNSEAL_CONFIG_1 à 3 de Secret Manager, sinon l'unsealer travaille avec celles de l'ancien coffre
  4. terraform apply sur 034.openbao-setup
  5. étape 1 de cette page

Les clés S3 et la clé de chiffrement de l'exporter viennent de Secret Manager, hors cluster : elles survivent à la perte du cluster et ne bloquent rien.

Séquence non éprouvée

Elle est déduite de la chaîne de dépendances des secrets, pas d'une reconstruction réelle. L'étape 1 et les suivantes, elles, ont été jouées.


1. Ouvrir un pod de travail

Depuis tooling-infra/build/k8s-openbao-restore, le nom du pod est daté du jour pour savoir quand il a été créé et ne pas entrer en collision avec un pod oublié :

POD=openbao-restore-$(date +%Y%m%d)

sed "s/name: openbao-restore$/name: $POD/" restore-pod.yaml | kubectl apply -f -
kubectl -n openbao-exporter wait --for=condition=Ready pod/$POD --timeout=120s

La seconde commande rend la main dès que le pod est prêt, en affichant condition met. Entrez alors dedans :

kubectl -n openbao-exporter exec -it $POD -- sh

Gardez ce terminal

La variable $POD n'existe que dans le shell où vous l'avez définie. Si vous ouvrez un autre terminal, retrouvez le nom avec kubectl -n openbao-exporter get pod | grep openbao-restore.

Toutes les commandes qui suivent se lancent dans ce pod. Il monte les identifiants nécessaires et son disque est éphémère.

L'export contient tous les secrets en clair

Il ne doit pas quitter le cluster. Le pod est là pour ça : son disque est éphémère et disparaît avec lui.

Dans le pod, déclarez la cible et votre token :

export VAULT_ADDR=https://openbao.svc.ademe-scw.fr

read -rsp "Root token OpenBao : " VAULT_TOKEN; echo
export VAULT_TOKEN

La saisie est masquée : le token n'apparaît ni à l'écran, ni dans l'historique du shell.


2. Choisir l'export

Les objets sont rangés par date, un par mount et par jour. Exemple pour les mounts applications et infrastructure :

2026/08/03/applications-export.json
2026/08/03/infrastructure-export.json

Les 5 derniers exports de chaque mount, avec le total disponible :

python -c "
import boto3, os
from collections import defaultdict
from botocore.client import Config
c = boto3.client('s3',
    endpoint_url='https://s3.%s.scw.cloud' % os.environ['S3_REGION'],
    aws_access_key_id=os.environ['S3_ACCESS_KEY'],
    aws_secret_access_key=os.environ['S3_SECRET_KEY'],
    region_name=os.environ['S3_REGION'],
    config=Config(signature_version='s3v4'))
par_mount = defaultdict(list)
for page in c.get_paginator('list_objects_v2').paginate(Bucket=os.environ['S3_BUCKET']):
    for o in page.get('Contents', []):
        mount = o['Key'].rsplit('/', 1)[-1].removesuffix('-export.json')
        par_mount[mount].append(o)
for mount in sorted(par_mount):
    print('%s (%d exports au total)' % (mount, len(par_mount[mount])))
    for o in sorted(par_mount[mount], key=lambda x: x['Key'], reverse=True)[:5]:
        print('   ', o['Key'], o['Size'], 'octets')
"

Utile pour vérifier que l'export du jour existe, ou pour remonter à une date antérieure si le dernier est suspect. Une taille qui s'effondre d'un jour à l'autre est un signal : l'export a peut-être réussi sur un coffre à moitié vide.


3. Télécharger et déchiffrer

--mount télécharge l'export du jour, soit le plus récent puisque le CronJob tourne à 3 h :

python /app/main.py --mount applications --destination /tmp/export.json

Pour toute autre date, désignez l'objet explicitement :

python /app/main.py --object-name 2026/07/31/applications-export.json --destination /tmp/export.json

Précisez toujours --mount ou --object-name

L'outil refuse de deviner. Il y a un export par mount et par jour, restaurer le mauvais serait silencieux.

La date est calculée en UTC

Entre minuit et 2 h locales, l'export « du jour » est celui de la veille. Utilisez --object-name si vous avez un doute, ou listez le bucket à l'étape précédente.

À ce stade, rien n'a été modifié dans OpenBao. Si cette étape échoue, la cause est dans l'accès au bucket ou dans le déchiffrement, pas dans le coffre.


4. Inspecter avant d'importer

python -m json.tool /tmp/export.json

Vérifiez que vous voyez bien ce que vous comptez restaurer : les bons projets, les bons environnements, les bonnes clés.

Cette commande affiche tous les secrets en clair

Elle se lance dans le pod, et le fichier y reste.


5. Importer

C'est l'étape qui pousse les données dans OpenBao

Jusqu'ici, rien n'a été modifié dans le coffre.

/app/medusa_import.sh -f /tmp/export.json -m applications

Ce que le script fait, dans cet ordre :

  1. il interroge sys/mounts pour savoir si le mount applications existe
  2. s'il est absent, il le crée en kv version 2
  3. il lance medusa import, qui écrit dans OpenBao chaque chemin contenu dans le fichier
  4. il affiche un Secret successfully written par chemin écrit

Tous les chemins du fichier sont écrits, pas seulement celui qui vous intéresse

L'export contient l'intégralité du mount à l'heure de la sauvegarde. Les secrets modifiés depuis reprennent donc leur valeur de cette sauvegarde.

Les valeurs actuelles ne sont pas perdues : chaque écriture crée une nouvelle version KV v2 et l'ancienne reste accessible. Mais il faudra les rétablir une par une.


6. Vérifier

Que l'aller-retour est sans perte : réexportez et comparez les données :

medusa export applications --format json --address $VAULT_ADDR --token $VAULT_TOKEN > /tmp/reexport.json
python -c "
import json
a = json.load(open('/tmp/export.json'))
b = json.load(open('/tmp/reexport.json'))
print('donnees identiques :', a == b)
"

Quelles versions ont été créées sur un chemin précis :

curl -sS -H "X-Vault-Token: $VAULT_TOKEN" \
  "$VAULT_ADDR/v1/applications/metadata/PROJET/ENV/DEPLOYMENT" | python -m json.tool

Le champ current_version doit avoir augmenté, et les versions précédentes apparaître avec "destroyed": false.


7. Nettoyer

kubectl -n openbao-exporter delete pod $POD

Si vous avez perdu la variable, listez les pods :

kubectl -n openbao-exporter get pod | grep openbao-restore

À ne pas oublier

Le disque du pod contient l'export déchiffré. Le pod s'arrête de lui-même au bout de 8 h, mais son disque reste lisible jusqu'à sa suppression.


Deux propriétés à connaître avant d'agir

Une restauration n'est pas un point de non-retour

Importer sur un chemin qui existe déjà crée une nouvelle version KV v2, l'ancienne reste intacte. En cas d'erreur, un retour à la version précédente est possible.

Le pod ne contient pas la CLI OpenBao, ce retour se fait depuis un poste qui l'a :

vault kv rollback -version=NUMERO applications/PROJET/ENV/DEPLOYMENT

Réserve : OpenBao conserve 10 versions par secret. max_versions n'est pas défini sur les mounts, c'est donc le défaut qui s'applique. Au-delà, la plus ancienne est supprimée définitivement. Des restaurations répétées consomment donc cette réserve.

Une restauration est une fusion, pas un remplacement

Medusa parcourt le fichier et écrit chaque chemin qu'il contient. Il ne supprime pas les clés présentes dans OpenBao mais absentes du fichier.

Restaurer un export d'il y a un mois ne ramène donc pas le coffre à son état d'il y a un mois : les anciennes valeurs sont réécrites, et tout ce qui a été créé depuis reste en place.

À vérifier si une décision en dépend

Ce comportement découle du fonctionnement de medusa mais n'a pas été testé. Éprouvez-le sur un mount jetable avant de vous appuyer dessus.


Si quelque chose échoue

Symptôme Cause probable
Provide --mount or --object-name ni mount ni objet précisé, l'outil refuse de deviner
NoSuchKey au téléchargement l'export de cette date n'existe pas, listez le bucket à l'étape 2
erreur 403 à l'import token en lecture seule, il faut le root token
no keys found au réexport le mount est vide, l'import n'a rien écrit
l'import s'arrête au milieu relancez-le, il est idempotent : chaque chemin est réécrit en nouvelle version

Lister les mounts existants

L'interface OpenBao n'affiche que les mounts couverts par vos policies. Pour voir ceux qui existent réellement, notamment après une reconstruction du coffre :

curl -sS -H "X-Vault-Token: $VAULT_TOKEN" "$VAULT_ADDR/v1/sys/mounts" \
  | python -c "import json,sys; print([k for k in json.load(sys.stdin)['data']])"

Accès d'urgence sans le cluster

Ce chemin sert quand le cluster est indisponible. Deux variantes : cinq étapes avec les CLI scw et aws, ou une image Docker à construire. La première ne demande aucun dépôt cloné, c'est celle à privilégier.

Prérequis

Quoi Pourquoi
CLI scw Key Manager est une API propre à Scaleway, aws ne sait pas lui parler
CLI aws le bucket est en S3
jq, openssl lire la réponse JSON, calculer l'empreinte de la clé
Réseau api.scaleway.com et s3.fr-par.scw.cloud
Root token OpenBao uniquement si vous réinjectez

Les deux CLI sont indispensables et ne se remplacent pas l'une l'autre : la première déchiffre, la seconde télécharge. La variante en conteneur évite de les installer, au prix d'un clone de dépôt et d'un build.

Si scw manque, c'est un binaire unique, sans dépendance :

URL=$(curl -s https://api.github.com/repos/scaleway/scaleway-cli/releases/latest \
  | grep -o '"browser_download_url": "[^"]*linux_amd64"' | sed 's/.*: "//;s/"$//')
curl -sL -o /tmp/scw "$URL" && chmod +x /tmp/scw

Les valeurs à récupérer

Valeur Où la trouver Rôle
s3-access-key Secret Manager, chemin /openbao-exporter accès au bucket et à Key Manager, c'est la même paire de clés
s3-secret-key idem idem
backup-dek-encrypted idem la clé qui a chiffré l'export, elle-même chiffrée
ID de l'organisation console Scaleway, page de l'organisation portée des appels à Key Manager
ID du projet console Scaleway, page du projet du cluster de service idem

Les exports vivent dans le bucket scw-openbao-backup, région fr-par, un objet par mount et par jour, nommé AAAA/MM/JJ/<mount>-export.json. La console Scaleway permet de les lister, pas de les télécharger : la clé de déchiffrement voyage dans la requête (SSE-C), la console ne l'envoie pas, et le téléchargement répond une erreur 400.

Préparer le shell

Remplacez les deux identifiants, puis saisissez les trois secrets — ils ne touchent ainsi ni le disque, ni l'historique.

export SCW_DEFAULT_ORGANIZATION_ID=<id-organisation>
export SCW_DEFAULT_PROJECT_ID=<id-projet-du-cluster-de-service>
export SCW_DEFAULT_REGION=fr-par

read -rp  "s3-access-key        : " SCW_ACCESS_KEY
read -rsp "s3-secret-key        : " SCW_SECRET_KEY; echo
read -rsp "backup-dek-encrypted : " CIPHERTEXT; echo
export SCW_ACCESS_KEY SCW_SECRET_KEY

# la meme paire de cles, sous les noms attendus par le CLI aws
export AWS_ACCESS_KEY_ID="$SCW_ACCESS_KEY"
export AWS_SECRET_ACCESS_KEY="$SCW_SECRET_KEY"
export AWS_DEFAULT_REGION=fr-par
export AWS_ENDPOINT_URL=https://s3.fr-par.scw.cloud

1. Ouvrir la clé

Le backup-dek-encrypted ne sert à rien tel quel : seul Key Manager peut l'ouvrir, la clé maîtresse ne sort jamais du service. On passe par un fichier parce qu'un secret donné en argument reste visible dans ps.

umask 077
printf '%s' "$CIPHERTEXT" > /tmp/dek.b64
unset CIPHERTEXT

KEY_ID=$(scw -o json keymanager key list name=scw-openbao-backup-key | jq -r '.keys[0].id')
KEY_B64=$(scw -o json keymanager key decrypt "$KEY_ID" ciphertext=@/tmp/dek.b64 | jq -r .plaintext)

rm -f /tmp/dek.b64

Contrôlez la clé sans l'afficher, la réponse doit être 32 :

printf '%s' "$KEY_B64" | base64 -d | wc -c

2. Calculer l'empreinte

S3 la compare à la clé reçue et rejette la requête si les deux ne concordent pas. Seule subtilité de la procédure : la clé se transmet en base64, l'empreinte se calcule sur la clé binaire.

KEY_MD5=$(printf '%s' "$KEY_B64" | base64 -d | openssl dgst -md5 -binary | base64)

3. Trouver le dernier export

Lister ne demande pas la clé. Aucune date à deviner : le nom des objets commence par la date, donc l'ordre alphabétique est l'ordre chronologique.

MOUNT=applications

OBJECT=$(aws s3 ls s3://scw-openbao-backup/ --recursive \
  | awk -v m="$MOUNT-export.json" '$4 ~ m {print $4}' | sort | tail -1)

echo "export retenu : $OBJECT"

4. Télécharger

aws s3api get-object \
  --bucket scw-openbao-backup \
  --key "$OBJECT" \
  --sse-customer-algorithm AES256 \
  --sse-customer-key "$KEY_B64" \
  --sse-customer-key-md5 "$KEY_MD5" \
  export.json

jq . export.json | head -20

Deux erreurs à ne pas confondre : une 400 signifie clé ou empreinte incohérentes, une 403 un problème de droits.

5. Nettoyer

Le fichier contient tous les secrets du mount, en clair

Ne remettez pas cette étape à plus tard.

rm -f export.json /tmp/dek.b64
unset KEY_B64 KEY_MD5 SCW_SECRET_KEY AWS_SECRET_ACCESS_KEY

Hors urgence, préférez le pod : son disque est éphémère et aucune clé ne transite par votre poste.

Variante en conteneur

L'image enchaîne les étapes 1 à 4 en un seul appel. Elle évite d'installer scw et aws, au prix du clone d'un dépôt et d'un build.

read -rp  "s3-access-key        : " S3_ACCESS_KEY
read -rsp "s3-secret-key        : " S3_SECRET_KEY; echo
read -rsp "backup-dek-encrypted : " SCW_DEK_ENCRYPTED_KEY; echo

export S3_ACCESS_KEY S3_SECRET_KEY SCW_DEK_ENCRYPTED_KEY \
       SCW_DEFAULT_PROJECT_ID SCW_DEFAULT_ORGANIZATION_ID

Construisez l'outil depuis le dépôt :

git clone git@gitlab.ademe.fr:ademe-group/infrastructure/tooling-infra.git
docker build -t openbao-restore:local tooling-infra/build/k8s-openbao-restore

Le téléchargement, ici l'export du jour du mount applications :

docker run --rm \
  --user "$(id -u):$(id -g)" -e HOME=/tmp \
  -e S3_ACCESS_KEY -e S3_SECRET_KEY -e SCW_DEK_ENCRYPTED_KEY \
  -e SCW_DEFAULT_PROJECT_ID -e SCW_DEFAULT_ORGANIZATION_ID \
  -e S3_BUCKET=scw-openbao-backup \
  -e S3_REGION=fr-par \
  -e SCW_DEK_MASTER_KEY=scw-openbao-backup-key \
  -v "$PWD:/out" \
  openbao-restore:local \
  --mount applications --destination /out/export.json

Les deux identifiants figurent aussi dans tooling-infra/build/k8s-openbao-restore/restore-pod.yaml. Pour une autre date, remplacez --mount applications par --object-name 2026/07/31/applications-export.json.

Vous obtenez export.json dans le répertoire courant, lisible avec cat export.json. La clé chiffrée a été ouverte par Key Manager au passage, vous n'avez rien à déchiffrer.

Le fichier contient tous les secrets du mount, en clair

Supprimez-le dès que vous avez ce que vous cherchiez :

rm export.json
unset S3_ACCESS_KEY S3_SECRET_KEY SCW_DEK_ENCRYPTED_KEY

Hors urgence, préférez le pod : son disque est éphémère et aucune clé ne transite par votre poste.

Réinjecter depuis votre poste

Cette étape passe forcément par l'image : le script d'import y est embarqué. Lisez d'abord ce qu'elle fait.

read -rsp "Root token OpenBao : " VAULT_TOKEN; echo
export VAULT_TOKEN

docker run --rm -it -v "$PWD:/out" \
  -e VAULT_ADDR=https://openbao.svc.ademe-scw.fr \
  -e VAULT_TOKEN \
  --entrypoint /app/medusa_import.sh \
  openbao-restore:local \
  -f /out/export.json -m applications

La clé de chiffrement, et ce qui arrive si on y touche

Un seul DEK chiffre toutes les sauvegardes. Il est stocké uniquement sous forme chiffrée, et c'est la clé scw-openbao-backup-key de Key Manager qui l'ouvre. Trois actions, trois conséquences très différentes.

Action Effet sur les sauvegardes
Rotation aucun. Les versions précédentes restent utilisables en déchiffrement, et le retour du decrypt fournit le ciphertext ré-emballé avec la version courante. Une rotation automatique tous les 30 jours est déjà active.
Désactivation plus aucun decrypt possible, donc le DEK reste scellé et les sauvegardes sont inexploitables. Réversible en réactivant la clé.
Suppression définitif. Supprimer la clé supprime toutes ses versions, et tout ce qu'elle protégeait devient indéchiffrable — le DEK compris. Les objets restent dans le bucket mais ne sont plus que des octets aléatoires.

Deux garde-fous limitent le dernier cas : la clé est déclarée protégée dans Terraform (074.key-manager/main.tf, unprotected = false) et Scaleway impose sept jours d'état « Scheduled for Deletion », pendant lesquels la clé est déjà inutilisable mais encore restaurable.

Point unique de défaillance

Aucune copie du DEK en clair n'existe. La restauration dépend donc entièrement d'une seule ressource Scaleway. Conserver le DEK déchiffré hors de Scaleway donnerait un chemin de secours indépendant, puisque SSE-C n'a besoin que de lui — au prix d'un secret en clair de plus, qui ouvrirait à lui seul toutes les sauvegardes passées. Arbitrage à faire, pas encore tranché.