diff --git a/ansible/arcodange/factory/playbooks/system/k3s_config.yml b/ansible/arcodange/factory/playbooks/system/k3s_config.yml index 6ecb7e1..4aa7e17 100644 --- a/ansible/arcodange/factory/playbooks/system/k3s_config.yml +++ b/ansible/arcodange/factory/playbooks/system/k3s_config.yml @@ -18,8 +18,22 @@ - name: how to reach k3s hosts: server + + vars: + # Plugin bouncer CrowdSec de Traefik. Ces trois variables sont la SEULE + # source de vérité : elles pilotent à la fois ce que la tâche + # « vendorer le plugin CrowdSec » télécharge et ce que Traefik va lire sur + # disque (cf. experimental.localPlugins plus bas). Monter de version = changer + # `traefik_crowdsec_plugin_version` puis rejouer `--tags traefik-plugins`. + traefik_crowdsec_plugin_module: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + traefik_crowdsec_plugin_version: v1.3.3 + # Sous-dossier du PVC monté sur /plugins-local/src/. Un PVC par + # plugin serait du gaspillage : on range chaque plugin dans son sous-dossier. + traefik_crowdsec_plugin_subpath: crowdsec-bouncer + traefik_plugins_pvc_name: traefik-plugins-local + tasks: - + # Tag `longhorn` : rejouer CETTE tâche seule (`--tags longhorn`). # ⚠ Sans tag, ce playbook exécute aussi « redeploy traefik » sur localhost, # qui SUPPRIME le Deployment traefik dans le contexte kubectl COURANT — @@ -64,6 +78,173 @@ vars: longhorn_helm_values: {} # https://github.com/longhorn/longhorn/blob/master/chart/values.yaml + # Tag `traefik-plugins` : rejouer CES tâches seules (`--tags traefik-plugins`), + # typiquement après avoir monté `traefik_crowdsec_plugin_version`. + # ⚠ Ces tâches DOIVENT rester avant l'écriture du manifeste Traefik : avec + # `abortOnPluginFailure: true`, un Traefik qui redémarre sur un PVC vide refuse + # de démarrer. Peupler d'abord, reconfigurer ensuite. + - name: vendorer le plugin CrowdSec sur un PVC + tags: [traefik-plugins] + delegate_to: localhost + become: false + block: + - name: Créer le PVC des sources de plugins Traefik + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: "{{ traefik_plugins_pvc_name }}" + namespace: kube-system + spec: + accessModes: [ReadWriteOnce] + storageClassName: longhorn + resources: + requests: + # Les sources du bouncer font 574 Ko décompressées (147 fichiers, + # vendor/ inclus). 128 Mi laisse de la place pour un second plugin + # sans avoir à retoucher au PVC. + storage: 128Mi + + - name: Lancer le Job qui pose les sources du plugin + kubernetes.core.k8s: + state: present + definition: + apiVersion: batch/v1 + kind: Job + metadata: + name: vendor-traefik-plugins + namespace: kube-system + spec: + backoffLimit: 2 + template: + spec: + restartPolicy: Never + # Même nœud que Traefik : le PVC est RWO, donc attaché à UN nœud. + # Plusieurs pods d'un même nœud peuvent le monter, ce qui permet + # de repeupler sans couper l'ingress — Traefik ne lit ses plugins + # qu'au démarrage, écrire pendant qu'il tourne est sans effet + # jusqu'à son prochain redémarrage. + nodeSelector: + node-role.kubernetes.io/control-plane: 'true' + tolerations: + - key: node-role.kubernetes.io/control-plane + operator: Exists + effect: NoSchedule + volumes: + - name: plugins + persistentVolumeClaim: + claimName: "{{ traefik_plugins_pvc_name }}" + containers: + - name: vendor + image: alpine:3.20 + env: + - name: MODULE + value: "{{ traefik_crowdsec_plugin_module }}" + - name: VERSION + value: "{{ traefik_crowdsec_plugin_version }}" + - name: SUBPATH + value: "{{ traefik_crowdsec_plugin_subpath }}" + command: + - /bin/sh + - -eu + - -c + - | + DEST="/plugins/$SUBPATH" + + # Idempotence : on ne retélécharge pas si la version posée + # est déjà la bonne ET que le manifeste est là (un PVC + # à moitié écrit ne doit pas passer pour valide). + if [ -f "$DEST/.vendored-version" ] \ + && [ "$(cat "$DEST/.vendored-version")" = "$VERSION" ] \ + && [ -f "$DEST/.traefik.yml" ]; then + echo "déjà vendoré en $VERSION, rien à faire" + exit 0 + fi + + TMP="$(mktemp -d)" + URL="https://plugins.traefik.io/public/download/$MODULE/$VERSION" + + # Ici, et seulement ici, on dépend du réseau. Contrairement + # au téléchargement que faisait Traefik au démarrage, on + # peut réessayer sans que l'ingress soit en jeu. + n=0 + until wget -q -T 30 -O "$TMP/plugin.zip" "$URL"; do + n=$((n + 1)) + if [ "$n" -ge 5 ]; then + echo "échec du téléchargement de $URL après 5 tentatives" >&2 + exit 1 + fi + echo "tentative $n échouée, nouvel essai dans 10 s" >&2 + sleep 10 + done + + mkdir -p "$TMP/x" + unzip -q "$TMP/plugin.zip" -d "$TMP/x" + + # L'archive du registre contient un unique dossier racine + # horodaté (maxlerebourg-...-). On ne devine pas son + # nom : on vérifie qu'il est bien unique. + if [ "$(find "$TMP/x" -mindepth 1 -maxdepth 1 -type d | wc -l)" -ne 1 ]; then + echo "archive inattendue : la racine n'est pas un dossier unique" >&2 + exit 1 + fi + SRC="$(find "$TMP/x" -mindepth 1 -maxdepth 1 -type d)" + + # Sans manifeste, Yaegi ne chargera rien et Traefik + # refusera de démarrer (abortOnPluginFailure). Autant + # s'en apercevoir ici. + if [ ! -f "$SRC/.traefik.yml" ]; then + echo "manifeste .traefik.yml absent de l'archive" >&2 + exit 1 + fi + + rm -rf "$DEST" + mkdir -p "$DEST" + cp -a "$SRC/." "$DEST/" + # Traefik tourne en 65532 et ne fait que lire. + chmod -R a+rX "$DEST" + echo "$VERSION" > "$DEST/.vendored-version" + + echo "plugin $MODULE@$VERSION posé dans $DEST" + ls "$DEST/.traefik.yml" "$DEST/vendor" >/dev/null + volumeMounts: + - name: plugins + mountPath: /plugins + + - name: Attendre la fin du Job + kubernetes.core.k8s_info: + api_version: batch/v1 + kind: Job + name: vendor-traefik-plugins + namespace: kube-system + register: vendor_job + until: >- + (vendor_job.resources[0].status.succeeded | default(0) | int) > 0 + or (vendor_job.resources[0].status.failed | default(0) | int) > 0 + retries: 30 + delay: 10 + + - name: Échouer si le Job n'a pas abouti + ansible.builtin.assert: + that: (vendor_job.resources[0].status.succeeded | default(0) | int) > 0 + fail_msg: >- + Le plugin n'a pas pu être posé sur le PVC. Ne pas reconfigurer Traefik + en l'état : avec abortOnPluginFailure il refuserait de démarrer. + Journal : kubectl logs -n kube-system job/vendor-traefik-plugins + + always: + - name: Nettoyer le Job + delegate_to: localhost + become: false + kubernetes.core.k8s: + state: absent + api_version: batch/v1 + kind: Job + name: vendor-traefik-plugins + namespace: kube-system + - name: customize k3s traefik configuration https://docs.k3s.io/helm block: - name: Get my public IP @@ -200,6 +381,15 @@ volumeMounts: - name: data mountPath: /data + # Sources du plugin CrowdSec, posées sur ce PVC par la tâche + # « vendorer le plugin CrowdSec ». Le chart EXIGE que le volume + # référencé par un localPlugin figure ici, sinon le rendu échoue + # (helper `traefik.volumeExistsInAdditionalVolumes`) — garde-fou + # utile : on ne peut pas déclarer le plugin sans son volume. + additionalVolumes: + - name: plugins-local + persistentVolumeClaim: + claimName: "{{ traefik_plugins_pvc_name }}" # default is https://github.com/traefik/traefik-helm-chart/blob/v25.0.0/traefik/values.yaml <- for v25 (`kubectl describe deployments.apps traefik -n kube-system | grep helm.sh/chart`) # current is https://github.com/traefik/traefik-helm-chart/blob/v37.4.0/traefik/values.yaml nodeSelector: @@ -260,10 +450,53 @@ mountPath: /config type: configMap experimental: - plugins: + # ⚠ PLUGIN LU SUR DISQUE, PLUS TÉLÉCHARGÉ AU DÉMARRAGE. + # + # `experimental.plugins` (registre distant) fait passer Traefik par + # RegistryDownloader.Download(), qui appelle plugins.traefik.io à + # CHAQUE démarrage, avec 10 s de timeout. Avoir déjà l'archive dans + # /plugins-storage ne dispense PAS de l'appel : le cache ne sert qu'à + # envoyer un hash pour obtenir un 304. Persister ce cache (en faire un + # PVC au lieu d'un emptyDir) n'aurait donc rien évité — vérifié dans + # la source de Traefik v3.6.2, pkg/plugins/downloader.go. + # + # Échec mesuré le 2026-09-20 à 15:00:50 : + # unable to install plugin crowdsec-bouncer: ... Get + # "https://plugins.traefik.io/.../v1.3.3": context deadline exceeded + # alors que le registre répondait en 214 ms depuis un pod de pi1 — + # c'était un hoquet, pas une panne. Ça a suffi. + # + # Traefik ne s'arrête pas pour autant : il journalise « Plugins are + # disabled because an error has occurred. » et sert quand même. Le + # middleware `crowdsec` devient alors « invalid middleware type or + # middleware does not exist » et Traefik JETTE tous les routeurs qui + # le référencent — c'est presque tout l'ingress. Mesuré : + # www.arcodange.lab et cms-rec.arcodange.lab en 404 pendant 3 h 18. + # + # `localPlugins` emprunte un autre chemin de code (SetupLocalPlugins), + # qui ne fait AUCUNE I/O réseau : Yaegi lit les sources sous + # /plugins-local/src/. C'est la même famille de fragilité que + # 2026-08-26 (résolution de la forge) : on ne fait pas dépendre l'edge + # d'un résolveur ou d'un registre joignable à la seconde près. + # + # ⚠ Monter la version du plugin ne se fait plus en changeant un champ + # que Traefik irait chercher : il faut REJOUER ce playbook + # (`--tags traefik-plugins`) pour repeupler le PVC. + localPlugins: crowdsec-bouncer: - moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin #https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin - version: v1.3.3 + moduleName: "{{ traefik_crowdsec_plugin_module }}" #https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin + mountPath: "/plugins-local/src/{{ traefik_crowdsec_plugin_module }}" + type: localPath + volumeName: plugins-local + subPath: "{{ traefik_crowdsec_plugin_subpath }}" + # Échouer franchement plutôt que servir sans bouncer. Sans ce drapeau, + # un plugin qui ne charge pas dégrade l'edge EN SILENCE (c'est toute + # l'histoire ci-dessus). Maintenant qu'il n'y a plus de réseau au boot, + # il ne peut se déclencher que si le PVC est vide ou corrompu — et dans + # ce cas on VEUT que Traefik refuse de démarrer plutôt qu'il ouvre + # l'ingress sans filtrage ni captcha. + # Requiert Traefik >= 3.3 (on est en 3.6.2). + abortOnPluginFailure: true additionalArguments: - '--providers.file.filename=/config/dynamic.yaml' - '--providers.kubernetesingress.ingressendpoint.publishedservice=kube-system/traefik'