Aller au contenu
Mon homelab de zéro (Partie 9) : OpenTofu, CI/CD, ou quand le pipeline révèle vos dépendances cachées
Logo OpenTofu
  1. Articles/

Mon homelab de zéro (Partie 9) : OpenTofu, CI/CD, ou quand le pipeline révèle vos dépendances cachées

·4361 mots·21 mins·
Sommaire
Mon homelab de zéro - Cet article fait partie d'une série.
Partie 9: Cet article

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.

note

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 403 pé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.changed

Comme 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é). Avec privsep 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-handler assumé : afficher un secret éphémère, c’est légitime) pour le capturer et le ranger immédiatement dans SOPS.
note

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-...
note

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 -p n’existe pas en zsh : ça sort read: -p: no coprocess. La syntaxe zsh, c’est read -rs "VAR?prompt". Le plus portable reste echo -n "prompt"; read -rs VAR.
  • Confusion nom / valeur : dans read -rs "VAR?prompt", VAR est le nom de la variable. Y coller le secret directement produit zsh: not an identifier et, pire, inscrit le secret dans l’historique. (Token régénéré et ~/.zsh_history nettoyé dans la foulée.)
  • Le ! = expansion d’historique : un ID de token Proxmox contient toujours un ! (tofu@pve!opentofu). Le coller tel quel donne zsh: 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
pve03

Le 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_env
note

Un 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 de pve01, 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 serial et lineage sont en clair : de la métadonnée de versionnement, sans valeur.
note

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: true

Le 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.

note

Attention, local n’est pas partagé entre nœuds. Le template est téléchargé sur pve01, donc le conteneur devra être créé . 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écuteur shell.
  • 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).

note

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/tofu

Deux 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_runner
note

Il y a trois mécanismes SOPS différents dans Ansible, et il faut savoir lequel s’applique :

  1. Le plugin de vars (community.sops.sops) déchiffre automatiquement les group_vars / host_vars - c’est ce qu’on a vu en partie 8.
  2. Le lookup (lookup('community.sops.sops', ...)) déchiffre explicitement, n’importe où - c’est la solution utilisée ici.
  3. vars_files ne déchiffre RIEN : Ansible lit le YAML chiffré tel quel et te refile des blobs ENC[...] 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: false

Le 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 exist

Le 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 127

Le 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 :

ErreurCause
yq: command not foundyq é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.

note

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
Le pipeline GitLab au vert : les trois stages test, security et plan avec les jobs secret_detection, sops-encrypted et tofu-plan tous réussis
Le pipeline enfin au vert : secret_detection, sops-encrypted et tofu-plan, une fois toutes les dépendances implicites débusquées

L’état des lieux après la phase 3
#

ÉlémentÉtat
Token API Proxmoxtofu@pve!opentofu, PVEAdmin + PVESysAdmin, révocable
State OpenTofuBackend GitLab, chiffré côté client (pbkdf2 + aes_gcm)
Providerbpg/proxmox, credentials par variables d’environnement
RunnerLXC 200 sur pve01, Debian 13.6, exécuteur shell
Outillage runnertofu, sops, yq, age, git
Pipelinesecret_detection + sops-encrypted + tofu-plan au vert, tofu-apply manuel
Secrets6 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 : 401 au tofu init. Action : régénérer et mettre à jour gitlab_token dans SOPS.
  • Affiner les permissions du token : le couple PVEAdmin + PVESysAdmin est 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 !

Kentrow
Auteur
Kentrow
Partage de notes et astuces IT : réseaux, serveurs, DevOps, sécurité, homelab et plus encore.
Mon homelab de zéro - Cet article fait partie d'une série.
Partie 9: Cet article

Articles connexes