Dans la partie 8, on a monté le cluster Proxmox à trois nœuds avec Ansible. On a maintenant une belle infrastructure… qu’on pilote toujours à la main, depuis mon ordinateur. Cette partie, c’est celle qui consolide les fondations : de quoi provisionner l’infra as code (OpenTofu), avec un state chiffré et une CI qui tourne dans le cluster pour exécuter tout ça.
Et c’est aussi la partie du fil rouge le plus universel de toute la série : le pipeline révèle tout ce que vous supposiez implicitement présent sur votre poste. On croit avoir une infra reproductible, jusqu’à ce qu’un runner tout neuf refuse de trouver yq, ou une clé SSH qui n’existe que chez vous.
Un petit paradoxe de poule et d’œuf assumé : le tout premier tofu apply se fait depuis mon ordinateur (il n’y a pas encore de CI ni de runner). C’est justement lui qui crée le runner GitLab, et c’est seulement une fois ce runner debout que le pipeline peut prendre le relais.
Étape 1 : un token API pour Proxmox#
Pour qu’OpenTofu puisse parler à Proxmox, il lui faut un accès API. Hors de question d’utiliser le mot de passe root : je crée un utilisateur dédié et un token révocable.
Quel niveau de privilèges ?#
La question mérite réflexion. Deux options :
- Un rôle large (
PVEAdmin) : simple, ça couvre tout ce dont on aura besoin. - Un rôle custom minimal : plus propre sur le papier, mais on découvre les permissions manquantes au fil de l’eau, avec des
Error 403pénibles à diagnostiquer.
J’ai choisi la simplicité, quitte à affiner plus tard une fois le jeu de permissions réellement utilisé connu. Le token étant dédié et révocable, le risque reste maîtrisé. Petit ajustement en cours de route quand même : PVEAdmin seul ne suffit pas, il manque Sys.Modify (nécessaire pour télécharger un fichier depuis une URL, ce qui servira dès la phase suivante). J’ajoute donc PVESysAdmin, en pensant qu’il apporte ce privilège. (Spoiler : ce n’est pas le cas mais je ne le découvrirai qu’en m’en servant vraiment. On y reviendra.)
Le rôle pve_api_token#
ansible/roles/pve_api_token/
├── defaults/main.yml # utilisateur, rôles, id du token
└── tasks/main.yml # création user + ACL + token, le tout idempotent# ansible/roles/pve_api_token/defaults/main.yml
---
pve_api_token_user: tofu@pve
pve_api_token_id: opentofu
pve_api_token_roles:
- PVEAdmin
- PVESysAdmin
pve_api_token_path: /# ansible/roles/pve_api_token/tasks/main.yml
---
- name: Check whether API user exists
ansible.builtin.command: "pveum user list --output-format json"
register: pve_api_token_users
changed_when: false
- name: Create API user
ansible.builtin.command: "pveum user add {{ pve_api_token_user }} --comment 'OpenTofu automation'"
when: pve_api_token_user not in (pve_api_token_users.stdout | from_json | map(attribute='userid') | list)
changed_when: true
- name: Read current ACL
ansible.builtin.command: "pveum acl list --output-format json"
register: pve_api_token_acl
changed_when: false
- name: Grant roles to API user
ansible.builtin.command: >-
pveum acl modify {{ pve_api_token_path }}
--users {{ pve_api_token_user }}
--roles {{ item }}
loop: "{{ pve_api_token_roles }}"
when: >-
pve_api_token_acl.stdout | from_json
| selectattr('ugid', 'equalto', pve_api_token_user)
| selectattr('roleid', 'equalto', item)
| list | length == 0
changed_when: true
- name: Check whether token exists
ansible.builtin.command: "pveum user token list {{ pve_api_token_user }} --output-format json"
register: pve_api_token_existing
changed_when: false
- name: Create API token
ansible.builtin.command: >-
pveum user token add {{ pve_api_token_user }} {{ pve_api_token_id }}
--privsep 0
--output-format json
register: pve_api_token_new
when: pve_api_token_id not in (pve_api_token_existing.stdout | from_json | map(attribute='tokenid') | list)
changed_when: true
- name: Show token secret
# noqa: no-handler - one-shot display of a secret shown only at creation
ansible.builtin.debug:
msg: >-
Token created. Store it now, it will not be shown again:
{{ (pve_api_token_new.stdout | from_json).value }}
when: pve_api_token_new.changedComme pour le stockage en partie 8, ce rôle ne s’exécute que sur le nœud primaire : le playbook cible hosts: pve mais conditionne la tâche avec when: inventory_hostname == pve_cluster_primary. Les utilisateurs, tokens et ACL vivent dans pmxcfs et se propagent au reste du cluster, inutile de les créer trois fois.
Deux points valent d’être soulignés :
--privsep 0: par défaut, un token Proxmox a ses propres permissions, distinctes de celles de son utilisateur (il faudrait lui poser un ACL séparé). Avecprivsep 0, il hérite des droits de l’utilisateur. On garde ainsi le contrôle au niveau utilisateur, et deux niveaux de révocation : le token seul, ou l’utilisateur entier.- Le secret n’est affiché qu’une seule fois, à la création. D’où la tâche
debug(avec un# noqa: no-handlerassumé : afficher un secret éphémère, c’est légitime) pour le capturer et le ranger immédiatement dans SOPS.
L’idempotence, encore et toujours. Chaque étape vérifie l’état avant d’agir (pveum user list, pveum acl list, pveum user token list). Une version précédente avait un changed_when: true en dur sur l’attribution des rôles → changed à chaque exécution. Le linter n’a rien vu : il attrape les conventions, pas la logique. Seul le test d’idempotence révèle ce genre de bug.
Le secret rangé dans SOPS#
Le token (et les autres secrets de la phase) atterrit dans secrets/tofu.sops.yaml. Voici les clés qu’il contient (valeurs chiffrées, évidemment) :
proxmox_api_token_id # tofu@pve!opentofu
proxmox_api_token_secret # l'UUID généré
gitlab_project_id # l'id du projet GitLab
gitlab_token # PAT de projet, scope api
tofu_state_passphrase # openssl rand -base64 32
gitlab_runner_token # glrt-...Moindre privilège partout. Le PAT GitLab est un token de projet (pas un token de compte) : il ne donne accès qu’à kentrowlab. Je lui ai même mis une expiration courte (7 jours) pour tester le renouvellement. Le jour où il expirera : une erreur 401 au tofu init, il suffira de régénérer le token et de mettre à jour la clé dans SOPS.
L’aparté zsh (parce qu’il faut en parler)#
Manipuler un token Proxmox dans un shell zsh, c’est un petit champ de mines. Trois pièges rencontrés, tous authentiques :
read -pn’existe pas en zsh : ça sortread: -p: no coprocess. La syntaxe zsh, c’estread -rs "VAR?prompt". Le plus portable resteecho -n "prompt"; read -rs VAR.- Confusion nom / valeur : dans
read -rs "VAR?prompt",VARest le nom de la variable. Y coller le secret directement produitzsh: not an identifieret, pire, inscrit le secret dans l’historique. (Token régénéré et~/.zsh_historynettoyé dans la foulée.) - Le
!= expansion d’historique : un ID de token Proxmox contient toujours un!(tofu@pve!opentofu). Le coller tel quel donnezsh: event not found. La parade : construire la valeur par substitution de commande, où le!n’est pas interprété.
Validation#
Un curl avec le token confirme que l’API répond :
TOKEN_ID=$(sops -d secrets/tofu.sops.yaml | yq -r '.proxmox_api_token_id')
TOKEN_SECRET=$(sops -d secrets/tofu.sops.yaml | yq -r '.proxmox_api_token_secret')
AUTH="PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}"
curl -sk -H "Authorization: ${AUTH}" https://192.168.3.11:8006/api2/json/nodes | jq -r '.data[].node'pve01
pve02
pve03Le token voit les trois nœuds. OpenTofu peut prendre la main.
Étape 2 : OpenTofu, backend GitLab et state chiffré#
C’est le cœur de la phase. On veut qu’OpenTofu stocke son state (l’état de l’infra) dans GitLab, et surtout qu’il soit chiffré côté client : même si le backend GitLab fuitait, le state doit rester illisible.
Une contrainte technique qui structure tout#
Le bloc encryption d’OpenTofu est évalué très tôt, avant les variables. Impossible donc d’y injecter la passphrase via une var.. La méthode prévue, c’est la variable d’environnement TF_ENCRYPTION, qui porte toute la config de chiffrement.
D’où un script env.sh : il déchiffre SOPS et exporte tout ce qu’il faut (backend, chiffrement, credentials Proxmox). Comme render.sh en partie 7, il ne contient aucun secret en dur : tout vient de SOPS à l’exécution.
# tofu/env.sh
#!/usr/bin/env bash
_tofu_env() {
local stack="${1:-core}"
local root secrets plain project_id gl_token address passphrase
root="$(git rev-parse --show-toplevel 2>/dev/null)"
if [ -z "$root" ]; then
echo "error: run from inside the git repository" >&2
return 1
fi
secrets="$root/secrets/tofu.sops.yaml"
plain="$(sops -d "$secrets")" || { echo "error: cannot decrypt $secrets" >&2; return 1; }
_get() { printf '%s' "$plain" | yq -r ".$1"; }
project_id="$(_get gitlab_project_id)"
gl_token="$(_get gitlab_token)"
passphrase="$(_get tofu_state_passphrase)"
address="https://gitlab.com/api/v4/projects/${project_id}/terraform/state/${stack}"
export TF_HTTP_ADDRESS="$address"
export TF_HTTP_LOCK_ADDRESS="${address}/lock"
export TF_HTTP_UNLOCK_ADDRESS="${address}/lock"
export TF_HTTP_LOCK_METHOD="POST"
export TF_HTTP_UNLOCK_METHOD="DELETE"
export TF_HTTP_USERNAME="gitlab-ci-token"
export TF_HTTP_PASSWORD="$gl_token"
export TF_ENCRYPTION="key_provider \"pbkdf2\" \"state\" {
passphrase = \"${passphrase}\"
}
method \"aes_gcm\" \"state\" {
keys = key_provider.pbkdf2.state
}
state {
method = method.aes_gcm.state
enforced = true
}
plan {
method = method.aes_gcm.state
enforced = true
}"
export PROXMOX_VE_API_TOKEN="$(_get proxmox_api_token_id)=$(_get proxmox_api_token_secret)"
local bootstrap
bootstrap="$(sops -d "$root/secrets/bootstrap.sops.yaml")" || return 1
export PROXMOX_VE_SSH_USERNAME="root"
export PROXMOX_VE_SSH_PRIVATE_KEY="$(printf '%s' "$bootstrap" | yq -r '.ssh_private_key')"
export TF_VAR_ssh_public_key="$(printf '%s' "$bootstrap" | yq -r '.ssh_public_key')"
# ... (d'autres TF_VAR viendront pour les phases suivantes)
echo "OpenTofu environment ready for stack: $stack"
}
_tofu_env "$@"
unset -f _tofu_envUn script sourcé ne se code pas comme un script exécuté. Ma première version avait un set -euo pipefail en tête. Comme on source ce script, ces options s’appliquent au shell interactif : la moindre commande qui échoue ferme le terminal (terminated with exit code: 100). Deuxième piège, ${BASH_SOURCE[0]} n’existe pas en zsh. La parade : pas de set -e (erreurs gérées par return 1 dans une fonction), et git rev-parse --show-toplevel pour trouver la racine, indépendamment du shell.
Un détail du bloc TF_ENCRYPTION mérite qu’on s’y arrête : le enforced = true, posé sur le state comme sur le plan. Ça ne veut pas dire “on chiffre si possible”, mais “OpenTofu refuse d’écrire quoi que ce soit en clair”. Pas de repli silencieux le jour où la passphrase ne serait pas chargée : plutôt une erreur franche qu’un state en clair par accident.
Le stack core#
tofu/stacks/core/
├── versions.tf # required_version, provider bpg, backend http vide
├── providers.tf # endpoint, ssh
├── variables.tf # les variables du stack
├── main.tf # data source de vérification
└── runner.tf # (étape 4)Le versions.tf déclare le provider et un backend http vide : toute sa config vient des variables d’environnement, donc aucun secret ni URL spécifique dans le code, il est publiable tel quel.
# tofu/stacks/core/versions.tf
terraform {
required_version = ">= 1.12.0"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.100"
}
# (les providers Talos, Helm et Kubernetes viendront aux phases suivantes)
}
# Credentials and encryption come from env vars (see tofu/env.sh)
backend "http" {}
}# tofu/stacks/core/providers.tf
provider "proxmox" {
endpoint = var.proxmox_endpoint
insecure = true
# API token and SSH credentials come from environment (see tofu/env.sh)
ssh {
agent = false
}
}Le insecure = true n’est pas une négligence : en interne, l’API Proxmox tourne avec un certificat auto-signé. Les certificats valides (via ktw.ovh et Let’s Encrypt) viendront dans une phase dédiée ; pour l’instant, on assume le self-signed sur le LAN.
Les variables du stack, réduites à ce qui sert en phase 3 (le fichier grossira ensuite avec Talos, Cilium, ArgoCD…) :
# tofu/stacks/core/variables.tf
variable "proxmox_endpoint" {
description = "Proxmox VE API endpoint"
type = string
default = "https://192.168.3.11:8006/"
}
variable "runner_node" {
description = "Node hosting the GitLab runner container"
type = string
default = "pve01"
}
variable "runner_template" {
description = "LXC template file id (must exist on runner_node)"
type = string
default = "local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst"
}
variable "runner_vm_id" {
description = "Container id"
type = number
default = 200
}
variable "runner_ip" {
description = "Static IPv4 with CIDR"
type = string
default = "192.168.3.50/24"
}
variable "runner_gateway" {
type = string
default = "192.168.3.1"
}
variable "runner_dns" {
type = string
default = "1.1.1.1"
}
variable "storage_pool" {
description = "ZFS pool backing container volumes"
type = string
default = "nvme-vm"
}
variable "ssh_public_key" {
description = "Public key injected into containers (set via TF_VAR_ssh_public_key)"
type = string
}Et le main.tf, pour l’instant, se contente d’une vérification en lecture seule : est-ce que le provider atteint bien le cluster ?
# tofu/stacks/core/main.tf
data "proxmox_virtual_environment_nodes" "cluster" {}
output "cluster_nodes" {
description = "Nodes seen through the API"
value = data.proxmox_virtual_environment_nodes.cluster.names
}Prouver le chiffrement, pas le supposer#
C’est mon moment préféré de la phase. Après source tofu/env.sh core, un tofu init puis un tofu apply (qui ne crée aucune ressource, mais initialise le state côté GitLab). Ensuite, on va regarder ce que GitLab stocke réellement :
curl -s -u "gitlab-ci-token:$TF_HTTP_PASSWORD" "$TF_HTTP_ADDRESS" | head -c 400{"serial":1,"lineage":"31f7af38-...","meta":{"key_provider.pbkdf2.state":
"eyJzYWx0IjoiRDlxTUpDUnZ4WURkUHBIZWF3ZURmWlhOblZLK3ZoNExDM2Z2Z3U0NittOD0i
LCJpdGVyYXRpb25zIjo2MDAwMDAsImhhc2hfZnVuY3Rpb24iOiJzaGE1MTIiLCJrZXlfbGVuZ
3RoIjozMn0="},"encrypted_data":"TfPgZfdLvDWiHTp9WWGotfF730BOX2A+..."}Décryptage (au sens figuré) :
encrypted_data: un blob illisible. Aucune trace depve01, de l’endpoint, de quoi que ce soit.key_provider.pbkdf2.state: juste les métadonnées de dérivation (sel, 600 000 itérations, SHA-512). Pas la clé.- Seuls
serialetlineagesont en clair : de la métadonnée de versionnement, sans valeur.
C’est la meilleure raison de choisir OpenTofu plutôt que Terraform, bien plus parlante que le débat de licence : le chiffrement du state côté client, natif. Même si le backend GitLab fuitait, le state reste indéchiffrable sans la passphrase, elle-même dans SOPS, protégée par la clé age. La chaîne est cohérente de bout en bout.
Deux accrocs pre-commit au passage, sans gravité : terraform_fmt échoue au premier commit (c’est un hook auto-correcteur : il reformate puis s’arrête pour que tu valides, un git add + recommit suffit), et tflint a râlé sur une variable déclarée mais non utilisée (ajoutée « au cas où » : supprimée, c’est exactement ce que le linter cherche à éviter).
Étape 3 : le template LXC (Ansible)#
Avant de créer un conteneur, il faut son template. Et là, c’est Ansible le bon outil, pas OpenTofu : pveam est une commande locale, et le provider ne gère pas le catalogue Proxmox.
ansible/roles/pve_lxc_template/
├── defaults/main.yml # motif de template + storage cible
└── tasks/main.yml # refresh catalogue + download idempotent# ansible/roles/pve_lxc_template/defaults/main.yml
---
pve_lxc_template_pattern: debian-13-standard
pve_lxc_template_storage: local# ansible/roles/pve_lxc_template/tasks/main.yml
---
- name: Refresh appliance catalog
ansible.builtin.command: pveam update
changed_when: false
- name: Find latest matching template
ansible.builtin.shell: |
set -o pipefail
pveam available --section system \
| awk '{print $2}' \
| grep "^{{ pve_lxc_template_pattern }}" \
| sort -V | tail -1
args:
executable: /bin/bash
register: pve_lxc_template_latest
changed_when: false
failed_when: pve_lxc_template_latest.stdout | length == 0
- name: List downloaded templates
ansible.builtin.command: "pveam list {{ pve_lxc_template_storage }}"
register: pve_lxc_template_present
changed_when: false
- name: Download template
ansible.builtin.command: >-
pveam download {{ pve_lxc_template_storage }}
{{ pve_lxc_template_latest.stdout }}
when: pve_lxc_template_latest.stdout not in pve_lxc_template_present.stdout
changed_when: trueLe rôle rafraîchit le catalogue, trouve la dernière version qui matche un motif (debian-13-standard), et la télécharge si elle est absente. Résultat : local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst.
À noter : ce téléchargement-ci passe par pveam en SSH root, pas par le token API. Ce n’est donc pas lui qui exigeait le Sys.Modify ajouté en étape 1 - ça, c’est pour les téléchargements via l’API OpenTofu, qui viendront plus tard.
Attention, local n’est pas partagé entre nœuds. Le template est téléchargé sur pve01, donc le conteneur devra être créé là. Pour déployer ailleurs, il faudrait aussi télécharger le template sur cet autre nœud. C’est une subtilité Proxmox qui piège vite.
Étape 4 : le runner GitLab, créé par OpenTofu#
Enfin la première vraie ressource créée en code : un conteneur LXC qui hébergera le runner GitLab.
# tofu/stacks/core/runner.tf
resource "proxmox_virtual_environment_container" "gitlab_runner" {
node_name = var.runner_node
vm_id = var.runner_vm_id
# Unprivileged: container root is not host root
unprivileged = true
start_on_boot = true
initialization {
hostname = "runner01"
ip_config {
ipv4 {
address = var.runner_ip
gateway = var.runner_gateway
}
}
dns {
servers = [var.runner_dns]
}
user_account {
keys = [trimspace(var.ssh_public_key)]
}
}
cpu {
cores = 2
}
memory {
dedicated = 2048
swap = 512
}
disk {
datastore_id = var.storage_pool
size = 20
}
network_interface {
name = "eth0"
bridge = "vmbr0"
}
operating_system {
template_file_id = var.runner_template
type = "debian"
}
# Nesting is required to run containerised CI jobs later
features {
nesting = true
}
tags = ["ci", "managed-by-tofu"]
lifecycle {
# Placement and run state belong to the HA manager, not to the code
ignore_changes = [node_name, started]
}
}Les choix qui comptent :
unprivileged = true: le root du conteneur n’est pas le root de l’hôte. Le défaut recommandé.nesting = true: nécessaire pour lancer plus tard des jobs CI conteneurisés. Sans ça, on est limité à l’exécuteurshell.- Disque sur
nvme-vm: première utilisation concrète du pool ZFS de la partie 8. ignore_changes = [node_name, started]: le placement et l’état de marche appartiennent au gestionnaire de HA de Proxmox, pas au code. Une fois la HA activée (dans une phase ultérieure), on évite ainsi qu’OpenTofu ne veuille “corriger” un nœud sur lequel la VM aurait migré.
Un détail qui boucle avec l’étape précédente : template_file_id pointe vers le template téléchargé à l’étape 3. Et comme local n’est pas partagé entre nœuds, ce template n’existe que sur pve01 - ce qui force runner_node à pointer sur pve01. La contrainte Proxmox de l’étape 3 dicte directement le placement de la ressource ici.
Le bug mountpoint=none : la partie 8 revient me hanter#
Souvenez-vous : en partie 8, j’avais insisté sur le -m /nvme-vm du pool ZFS, avec une note du genre “leçon apprise à la dure”. Voici la dure leçon en question, révélée pile à ce moment, au tout premier conteneur :
Error: unable to create CT 200 - zfs error:
cannot mount 'nvme-vm/subvol-200-disk-0': no mountpoint setÀ l’origine, mon pool était créé avec -m none (“Proxmox gère les datasets lui-même”). Ce raisonnement est vrai pour les VM : elles utilisent des zvol, des périphériques bloc sans point de montage. Mais faux pour les conteneurs LXC : eux utilisent des subvol, des datasets ZFS que Proxmox doit monter. Les enfants héritant de mountpoint=none, le montage échoue.
Le correctif : zfs set mountpoint=/nvme-vm nvme-vm sur les trois nœuds, et surtout la correction du rôle pve_zfs (le -m /nvme-vm que vous avez vu en partie 8, plus la tâche zfs set pour les pools déjà existants).
Une décision de la phase 2 dont la conséquence n’apparaît qu’en phase 3. L’erreur ne se manifeste qu’au premier conteneur, longtemps après la création du pool. C’est l’exemple parfait d’un choix qui “marche” tant qu’on ne s’en sert pas de la bonne façon. La règle à retenir : un pool ZFS destiné à Proxmox ne doit pas avoir mountpoint=none si on compte y créer des conteneurs.
Une fois le pool corrigé, le conteneur se crée sans broncher : runner01, Debian 13.6, 2 Go de RAM, disque de 20 Go sur nvme-vm.
Étape 5 : le runner et le pipeline, ou le grand test de vérité#
Le conteneur existe, il faut maintenant y installer et enregistrer le runner GitLab, puis écrire le pipeline. C’est là que le fil rouge de la phase se déchaîne.
Le rôle gitlab_runner#
# ansible/roles/gitlab_runner/tasks/main.yml (extrait)
- name: Register runner
ansible.builtin.command: >-
gitlab-runner register
--non-interactive
--url {{ gitlab_runner_url }}
--token {{ gitlab_runner_token }}
--executor {{ gitlab_runner_executor }}
--shell bash
--name {{ gitlab_runner_name }}
when: gitlab_runner_name not in gitlab_runner_list.stderr
changed_when: true
no_log: true
- name: Install yq
ansible.builtin.get_url:
url: https://github.com/mikefarah/yq/releases/download/v4.44.6/yq_linux_amd64
dest: /usr/local/bin/yq
mode: "0755"
- name: Install sops
ansible.builtin.get_url:
url: https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
dest: /usr/local/bin/sops
mode: "0755"
- name: Install opentofu
ansible.builtin.shell: |
set -o pipefail
curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-tofu.sh
chmod +x /tmp/install-tofu.sh
/tmp/install-tofu.sh --install-method standalone --skip-verify
rm -f /tmp/install-tofu.sh
args:
executable: /bin/bash
creates: /usr/local/bin/tofuDeux choix : l’exécuteur shell (plutôt que docker) - plus simple, suffisant pour lancer tofu et ansible, et ça évite d’imbriquer Docker dans du LXC. Et la suite bookworm pour le dépôt du runner (GitLab ne publie pas encore pour trixie, les paquets sont compatibles).
Notez surtout la fin du rôle : on installe explicitement les outils dont le pipeline aura besoin sur le runner - yq, sops, opentofu et age (l’extrait ci-dessus en montre trois ; age suit exactement le même modèle get_url). Retenez ça, on va comprendre pourquoi dans un instant.
Trois façons de lire un secret SOPS dans Ansible#
Le playbook doit passer le gitlab_runner_token (chiffré dans SOPS) à la commande d’enregistrement. Ma première version utilisait vars_files: [../secrets/tofu.sops.yaml]… et l’enregistrement échouait, en silence (à cause du no_log: true).
# ansible/gitlab-runner.yml
---
- name: Install and register GitLab runner
hosts: ci
become: false
gather_facts: true
vars:
# vars_files does not decrypt SOPS; the lookup plugin does
kentrowlab_secrets: >-
{{ lookup('community.sops.sops', playbook_dir + '/../secrets/tofu.sops.yaml') | from_yaml }}
gitlab_runner_token: "{{ kentrowlab_secrets.gitlab_runner_token }}"
roles:
- gitlab_runnerIl y a trois mécanismes SOPS différents dans Ansible, et il faut savoir lequel s’applique :
- Le plugin de vars (
community.sops.sops) déchiffre automatiquement lesgroup_vars/host_vars- c’est ce qu’on a vu en partie 8. - Le lookup (
lookup('community.sops.sops', ...)) déchiffre explicitement, n’importe où - c’est la solution utilisée ici. vars_filesne déchiffre RIEN : Ansible lit le YAML chiffré tel quel et te refile des blobsENC[...]comme valeurs.
Et un corollaire : no_log: true protège les secrets dans les logs, mais rend le débogage aveugle. Le réflexe quand un truc cloche : le désactiver temporairement pour diagnostiquer, puis le remettre. Jamais le laisser désactivé “au cas où”.
Le seul secret posé à la main#
Pour que les pipelines puissent déchiffrer SOPS, le runner a besoin de la clé age privée. Elle est posée manuellement dans GitLab (Settings → CI/CD → Variables), en Masked + Protected, sous le nom SOPS_AGE_KEY. C’est le seul secret déposé à la main de tout le projet : tout le reste en découle. Fidèle à la logique de la phase 0 (clé de travail sur mon ordinateur, secours dans Bitwarden, copie CI en variable protégée).
Le pipeline#
# .gitlab-ci.yml
include:
- template: Security/Secret-Detection.gitlab-ci.yml
stages:
- test
- security
- plan
- apply
default:
tags:
- kentrowlab
variables:
TF_STACK: core
secret_detection:
# Docker-based template: keep it on GitLab.com shared runners
tags: []
sops-encrypted:
stage: security
script:
- |
status=0
files=$(find . -type f \( -name '*.sops.yaml' -o -name '*.sops.yml' \
-o -name '*.sops.json' -o -name '*.sops.env' \) \
! -path './.sops.yaml')
for f in $files; do
if grep -q 'ENC\[' "$f"; then echo "OK (encrypted): $f";
else echo "ERROR (PLAINTEXT): $f"; status=1; fi
done
exit $status
.tofu_base:
before_script:
- mkdir -p ~/.config/sops/age
- echo "$SOPS_AGE_KEY" > ~/.config/sops/age/keys.txt
- chmod 600 ~/.config/sops/age/keys.txt
- source tofu/env.sh "$TF_STACK"
- cd "tofu/stacks/$TF_STACK"
- tofu init -input=false
after_script:
- rm -f ~/.config/sops/age/keys.txt
tofu-plan:
extends: .tofu_base
stage: plan
script:
- tofu plan -input=false -out=tfplan
- tofu show -no-color tfplan > plan.txt
artifacts:
paths:
- tofu/stacks/$TF_STACK/plan.txt
expire_in: 1 week
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
tofu-apply:
extends: .tofu_base
stage: apply
script:
- tofu apply -input=false -auto-approve
rules:
# Manual gate: a merge should never silently destroy infrastructure
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
allow_failure: falseLe tofu-plan tourne à chaque merge request et sur la branche par défaut ; le tofu-apply, lui, est manuel : un merge ne doit jamais détruire l’infra en silence, il faut un clic humain.
Les trois accrocs du pipeline (le vrai cœur de l’article)#
1. Le stage test manquant.
secret_detection job: chosen stage test does not existLe template Secret-Detection de GitLab utilise le stage test, que j’avais retiré de ma liste. Réintroduit.
2. secret_detection incompatible avec l’exécuteur shell.
ERROR: Job failed: exit status 127Le template suppose un exécuteur Docker. Avec shell, le runner ignore le image: et tente de lancer un binaire absent (127 = commande introuvable). La parade : tags: [] pour renvoyer ce job vers les shared runners de GitLab.com (il n’a pas besoin d’accéder au LAN). Corollaire : l’exécuteur shell impose que tous les outils soient présents sur le runner - pas d’image Docker qui les apporte. C’est le prix de la simplicité.
3. Les dépendances implicites - LE fil rouge.
Et là, trois échecs successifs, tous de la même famille :
| Erreur | Cause |
|---|---|
yq: command not found | yq était sur mon ordinateur, oublié dans le rôle du runner |
no file exists at "~/.ssh/kentrowlab_ed25519" | une clé privée présente seulement sur mon ordinateur |
no file exists at "~/.ssh/kentrowlab_ed25519.pub" | idem pour la clé publique |
Le correctif : yq ajouté au rôle du runner (d’où l’installation explicite vue plus haut), et les deux clés SSH proviennent désormais d’un fichier SOPS dédié au bootstrap (bootstrap.sops.yaml, distinct de tofu.sops.yaml qui porte les secrets applicatifs), lu par env.sh - plus d’un fichier local. La variable ssh_public_key n’a d’ailleurs aucune valeur par défaut : OpenTofu échoue explicitement si elle manque, plutôt que de tenter de lire un fichier absent.
L’enseignement central de la phase. Une configuration qui dépend d’un fichier sur votre machine n’est pas portable. Le pipeline est le premier environnement réellement neutre : il révèle tout ce qu’on supposait implicitement présent sur son poste. Faire passer un job en CI, c’est le vrai test de reproductibilité d’une infra as code - bien plus qu’un tofu apply local réussi.
Validation finale#
gitlab-runner 19.2.0 - OpenTofu v1.12.5 - sops 3.9.4 - service active
Pipeline: secret_detection OK | sops-encrypted OK | tofu-plan OK
L’état des lieux après la phase 3#
| Élément | État |
|---|---|
| Token API Proxmox | tofu@pve!opentofu, PVEAdmin + PVESysAdmin, révocable |
| State OpenTofu | Backend GitLab, chiffré côté client (pbkdf2 + aes_gcm) |
| Provider | bpg/proxmox, credentials par variables d’environnement |
| Runner | LXC 200 sur pve01, Debian 13.6, exécuteur shell |
| Outillage runner | tofu, sops, yq, age, git |
| Pipeline | secret_detection + sops-encrypted + tofu-plan au vert, tofu-apply manuel |
| Secrets | 6 clés dans secrets/tofu.sops.yaml, SOPS_AGE_KEY en variable CI |
On a désormais une vraie chaîne d’automatisation : je pousse du code, le pipeline le vérifie, le lint, planifie les changements, et je peux les appliquer d’un clic. Et le state est chiffré de bout en bout. Mais surtout, cette phase m’a appris la leçon la plus utile de tout le projet : le seul vrai test d’une infra reproductible, c’est de la faire tourner ailleurs que chez soi.
Points encore ouverts#
- Le PAT GitLab expire dans 7 jours (volontaire, pour tester le renouvellement). Symptôme attendu :
401autofu init. Action : régénérer et mettre à jourgitlab_tokendans SOPS. - Affiner les permissions du token : le couple
PVEAdmin+PVESysAdminest large et, on l’a vu, pas forcément suffisant, on y reviendra.
Et maintenant ?#
Le socle est là : OpenTofu pilote le cluster, le state est chiffré, la CI tourne dans le lab. Dans la partie 10, on attaque les golden images : des templates cloud-init reproductibles avec Packer, et l’image Talos récupérée depuis l’Image Factory. Bref, de quoi fabriquer proprement les VM qui accueilleront bientôt Docker et Kubernetes.
À bientôt !




