Aller au contenu

Pas-à-pas

Les pages précédentes ont fixé les six couches, leurs dépendances et pourquoi cet ordre ne se permute pas. Cette page exécute : chaque étape porte sa commande, sa vérification, et le symptôme qui apparaît si on l'enchaîne trop tôt. Une vérification qui se contente de constater qu'une commande a répondu, sans constater que l'étape a produit son effet, est écartée volontairement : elle transformerait une panne immédiate en panne découverte plusieurs étapes plus loin, sans lien visible avec sa cause. Cette page ne revient pas sur le pourquoi déjà détaillé en Architecture et Stratégie, sauf pour le point qui n'était écrit nulle part avant cette page : l'ordre exact d'amorçage entre les secrets et les certificats, qui casse une dépendance circulaire apparente. Cette page le fait d'abord, avant les commandes, parce que s'en écarter bloque l'installation d'une façon qui ne se diagnostique pas facilement.

Avant de commencer

  • La fiche client est remplie : Paramètres.
  • scripts/preflight.sh --env scripts/install.env --skip-cluster passe : Prérequis.
  • Les six machines virtuelles existent côté hyperviseur, mais aucune n'a encore de configuration Talos appliquée.

Pourquoi les secrets viennent avant les certificats

À première vue, les secrets et les certificats se référencent l'un l'autre. Le ClusterIssuer Let's Encrypt (certs/letsencrypt-issuer.yaml) résout son jeton Cloudflare via le Secret cloudflare-dns-token, produit dans le namespace cert-manager par un ExternalSecret (core/cert-manager/cloudflare-dns-token.yaml) qui vise le ClusterSecretStore vault-store. Et l'interface web de Vault est elle-même exposée sous le domaine wildcard (core/platform-access/vault.yaml), un Ingress qui ne peut servir qu'un certificat déjà émis. Émettre le certificat semble donc exiger Vault, qui semble exiger le certificat.

La boucle ne se ferme pas, parce que Vault n'a jamais besoin de cet Ingress pour fonctionner. secrets/eso/vault-store.yaml joint Vault par son Service interne, en clair, dans le cluster :

server: "http://vault.vault.svc:8200"

Ce Service, vault.vault.svc:8200 dans le namespace vault, se résout par le DNS interne standard de Kubernetes dès que le CNI est en place, sans dépendre du CoreDNS personnalisé de la couche 4, du certificat wildcard, ni d'aucun Ingress. C'est ce Service que l'opérateur atteint directement depuis son poste, par kubectl port-forward ou kubectl exec, pour désceller Vault et y écrire le jeton Cloudflare que le ClusterIssuer attend. L'Ingress de la page platform-access n'ajoute qu'un accès web plus confortable à une UI déjà fonctionnelle, une fois le certificat émis : il n'est un prérequis de rien.

L'ordre qui en découle, détaillé étape par étape plus bas : Talos, la couche de base, le stockage, la VIP de service, Vault et son peuplement, le ClusterSecretStore, puis seulement à ce moment le Secret Cloudflare peut se matérialiser et les certificats peuvent s'émettre. Le reste de la couche réseau, la couche de données, les images applicatives, Keycloak et les applications suivent, dans cet ordre parce que chacun d'eux consomme quelque chose que l'étape précédente produit.

Le repli à la main existe, et son piège aussi

Il est possible de poser le Secret cloudflare-dns-token à la main (kubectl create secret generic cloudflare-dns-token -n cert-manager --from-literal=api-token=...) pour émettre le wildcard avant d'avoir peuplé Vault. Cela fonctionne au premier passage. Mais l'ExternalSecret du même nom reprendra la main dès que le ClusterSecretStore sera prêt, avec creationPolicy: Owner : il écrase le Secret à chaque synchronisation. Si le chemin Vault prod/cloudflare-dns n'a jamais été peuplé à ce moment-là, le jeton posé à la main disparaît sans qu'aucune erreur ne le signale sur le moment. L'échec n'apparaît qu'au renouvellement suivant du certificat, des semaines plus tard, loin de sa cause. Ne poser le jeton à la main que comme test transitoire, jamais comme état final.

1. Amorcer le cluster Talos

Commande talosctl gen secrets -o secrets.yaml, puis talosctl gen config tradeval-cluster https://${TV_CP_ENDPOINT}:6443 --with-secrets secrets.yaml, en indiquant ${TV_POD_SUBNET} et ${TV_SVC_SUBNET} dans la config générée ; talosctl apply-config --insecure -n <control-plane> --file controlplane.yaml sur chaque control-plane, worker.yaml sur chaque worker ; talosctl bootstrap -n <un control-plane>
Vérification kubectl get nodes : autant de noeuds en Ready que ${TV_NODE_COUNT}, six sur le dimensionnement de référence
Symptôme si échec Noeuds en NotReady, ou talosctl sans réponse : vérifier install.disk (doit désigner le disque système, distinct du disque Longhorn) et les extensions du profil worker. Détail complet : talos/README.md § « Régénérer / réinstaller un cluster depuis zéro » et § PROD

Générer un jeu de secrets Talos propre à cette instance : ne jamais réutiliser celui d'une autre installation. ${TV_CP_ENDPOINT} vit dans cette configuration machine et nulle part ailleurs, hors de portée de render-config.sh. ${TV_POD_SUBNET} et ${TV_SVC_SUBNET} y vivent aussi, mais pas seulement : render-config.sh les substitue dans core/vpn/peer-officer.template.conf, où le tunnel annonce ces deux plages aux agents. Les valeurs saisies ici et celles de la fiche doivent donc être les mêmes, sans quoi le tunnel monte, les agents se connectent et ne joignent rien derrière, sans erreur (Paramètres).

Le CNI : Canal, et un seul bloc cni à renseigner

Ce dépôt posait autrefois core/networking/calico.yaml en plus du Flannel par défaut de Talos, sans que personne ait observé ce que produisait la cohabitation. L'instance de référence l'a mesuré le 2026-09-08 : le Calico ainsi posé ne câblait aucun pod, mais il allouait des blocs d'adresses et installait des routes concurrentes de celles de Flannel, dont une plus spécifique qui aurait rendu injoignables les pods d'une plage entière. Ce fichier a été retiré du dépôt.

Le CNI retenu est Canal, core/networking/canal.yaml : Flannel pour le réseau, Calico pour les NetworkPolicy. Il demande deux choses, dans cet ordre : appliquer la configuration machine, dont le bloc cni est déjà posé à none, pour que Talos n'installe pas son propre Flannel, puis appliquer le manifeste. Jamais l'inverse : le DaemonSet canal embarque son propre flanneld, et deux démons se disputeraient le même bail de sous-réseau. Le port VXLAN du manifeste est aligné sur celui de Talos, 4789 : ne pas le modifier sans vérifier ce que le Flannel de Talos utilise, deux noeuds sur des ports différents ne se parlent pas.

2. Appliquer la fiche client aux manifests

scripts/render-config.sh --env scripts/install.env --apply
git diff --stat
Vérification Symptôme si échec
git diff --stat liste des dizaines de fichiers modifiés Diff vide : la fiche porte encore les valeurs d'origine du dépôt, render-config.sh n'a rien trouvé à substituer

Cette étape doit précéder toute application de manifest : les couches suivantes lisent des fichiers déjà réécrits avec le domaine, la VIP et les autres valeurs de la fiche.

3. Poser la couche de base

scripts/apply-manifests.sh
Vérification Symptôme si échec
kubectl get pods -n vault : trois pods vault-0, vault-1, vault-2 créés (pas encore Running, faute de stockage : voir l'étape suivante) ; kubectl get pods -n argocd : un pod en cours de démarrage Erreurs d'admission qui nomment une ressource précise : corriger cette ressource avant de rejouer la commande, plutôt que de rejouer à l'aveugle
for z in web internal api auth; do kubectl get pods -n $z; done : aucun pod, les quatre zones viennent d'être créées et restent vides Un pod déjà présent ici : le cluster n'est pas neuf, ou une commande d'une installation précédente a été rejouée. Le traiter avant de continuer, il fausserait la lecture de l'étape 13, « Applications et exposition »

Cette commande applique kubectl apply -k . : les namespaces, le réseau (Calico), la couche de données interne au cluster (Redis, Kafka, Elasticsearch), la pile observability héritée, cert-manager, Vault (le StatefulSet, pas encore son contenu), Traefik et ArgoCD. Elle pose aussi le Service LoadBalancer de Traefik, avec son adresse ${TV_VIP} déjà épinglée : c'est lui que l'étape 5, « Publier la VIP de service : kube-vip », fera annoncer.

Elle crée les quatre zones et n'y pose aucun objet : les onze workloads applicatifs arrivent à l'étape 13, « Applications et exposition », et pas avant. Un CrashLoopBackOff ou un ImagePullBackOff dans une de ces zones n'est donc jamais attendu, ni à cette étape ni à une autre, et ne se laisse jamais passer au motif qu'une dépendance manquerait encore (Troubleshooting).

Les quatre zones

Les workloads applicatifs ne partagent pas un namespace unique : ils sont répartis sur quatre zones de confiance. Partout où ce guide écrit -n <zone>, c'est dans cette table qu'on lit la valeur à mettre.

Zone Workloads Exposition
web hub-portail, portal-portail publique, par le tunnel
internal internal-portail réservée au VPN
api les sept trade-*-svc interne et VPN
auth keycloak et sa base keycloak-db publique, auth.${TV_DOMAIN}

Deux conséquences pratiques, toutes deux sources d'erreur si on les ignore :

  • Un Secret est un objet de namespace. acr-pull-secret doit exister dans les quatre zones, voir l'étape 12, « Images et secrets de registre ».
  • Un nom de Service court ne résout que dans sa propre zone. Un appel qui franchit une frontière s'écrit en entier, <service>.<zone>.svc.cluster.local. Les URL concernées sont dans Vault, pas dans les manifestes.

Nettoyage sur un cluster déjà installé : le contrôleur nginx

Ne concerne pas une installation neuve, où il n'y a rien à faire. Ce dépôt a longtemps posé, par sa couche de base, un Deployment nginx-ingress-controller à deux répliques dans le namespace ingress-nginx, en plus de Traefik. Il ne servait à rien : le fichier ne crée aucun Service, donc rien ne route vers lui ; il ne crée aucune IngressClass ; et les dix-sept Ingress du dépôt portent tous ingressClassName: traefik. Il n'était pas pour autant sans effet, puisqu'il tolère les taints control-plane et réserve 2 x 256 Mio, la propriété même qui a fait tuer les kube-apiserver de l'instance de référence (Troubleshooting, entrée 12).

kustomization.yaml ne le tire plus, et kubectl apply -k n'ayant pas de --prune, ce retrait ne supprime rien de ce qui tourne déjà. Sur un cluster installé avant ce changement, le supprimer une fois :

kubectl delete deployment nginx-ingress-controller -n ingress-nginx --ignore-not-found
Cette suppression est définitive : la couche de base ne le repose plus.

cert-manager vient de la couche de base, en v1.16.5

core/cert-manager/ porte le manifeste amont v1.16.5 tel quel, plus un patch kustomize qui ajoute les deux arguments DNS-01. Ils ne sont pas optionnels : par défaut, cert-manager vérifie la propagation du challenge en interrogeant CoreDNS, qui renvoie tout *.${TV_DOMAIN} vers Traefik. La vérification interrogerait donc Traefik au lieu d'un résolveur public, et échouerait à tous les coups. Vérifier après l'apply :

kubectl get deploy cert-manager -n cert-manager -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}{.spec.template.spec.containers[0].args}{"\n"}'
Attendu : une image v1.16.5, et --dns01-recursive-nameservers-only dans les arguments.

Ne pas descendre sous v1.16.4 : Cloudflare ne renvoie plus zone_id dans ses enregistrements DNS, et les versions antérieures échouent à supprimer le TXT du challenge, ce qui bloque l'Order après une validation pourtant réussie. Détail et montée de version : core/cert-manager/README.md.

Sur un cluster installé avant le 2026-09-15 depuis l'ancien cert-manager.yaml du dépôt, le Deployment porte un spec.selector différent (app: cert-manager), champ immuable : l'apply échoue en field is immutable. Supprimer d'abord ce Deployment et le compte de service de l'ancien manifeste, puis réappliquer :

kubectl delete deployment cert-manager -n cert-manager
kubectl delete serviceaccount cert-manager-sa -n cert-manager --ignore-not-found
kubectl apply -k core/cert-manager

4. Provisionner le stockage

Vault (étape 3, « Poser la couche de base ») et tout ce qui suit dépendent d'une StorageClass par défaut. Aucun manifeste de ce dépôt n'installe de contrôleur de stockage : grep -rn storageClassName sur tout le dépôt ne renvoie rien, et core/platform-access/longhorn.yaml n'est que l'Ingress de son interface, pas son installation. Les trois volumeClaimTemplates de secrets/vault.yaml restent Pending indéfiniment tant que ce point n'est pas traité.

Installer le contrôleur de stockage

docs/guide/02-prerequis.md documente l'extension Talos iscsi-tools et le point de montage /var/lib/longhorn des workers, en cohérence avec TV_STORAGE_CLASS=longhorn par défaut dans la fiche client. Longhorn lui-même vient du dépôt Helm amont, avec les valeurs du dépôt :

helm repo add longhorn https://charts.longhorn.io
helm repo update
helm upgrade --install longhorn longhorn/longhorn -n longhorn-system --create-namespace \
  --version 1.12.0 -f core/longhorn/longhorn-values.yaml
Ne pas installer sans ces valeurs. csi.kubeletRootDir est obligatoire sous Talos : s'il ne correspond pas au répertoire réel du kubelet, le plugin CSI démarre normalement et aucun volume ne se monte, l'erreur apparaissant sur les pods qui montent un PVC et pas sur Longhorn. defaultSettings.defaultReplicaCount vaut 2 et non 3, parce que les trois workers porteurs de stockage ont des disques déséquilibrés.

scripts/preflight.sh --env scripts/install.env
Vérification Symptôme si échec
PREFLIGHT : tous les controles passent., en particulier le contrôle 7, StorageClass par défaut aucune StorageClass par defaut, les PVC resteront Pending : le contrôleur de stockage n'est pas installé ou n'a pas encore posé sa StorageClass par défaut ; StorageClass par defaut = X, attendue = longhorn : une autre StorageClass a pris le rôle par défaut, à corriger avant de continuer
Le contrôle 8, couche de donnees, affiche toujours ses six lignes ok <NOM> <hote>:<port> injoignable : cet endpoint a cessé de répondre depuis le lancement de la page 02, qui l'exerçait déjà
kubectl get pods -n vault : les trois pods Running, pas seulement créés Toujours Pending : les PVC n'ont pas trouvé de volume, revérifier la StorageClass par défaut

C'est la première fois dans cette procédure que preflight.sh s'exécute sans --skip-cluster. Ce drapeau saute exactement les deux sections qui exigent un cluster, la joignabilité (6) et la StorageClass par défaut (7) : ce lancement-ci n'ajoute donc que ces deux-là. Les six endpoints de la couche de données, contrôle 8, étaient déjà exercés par le lancement de la page 02.

5. Publier la VIP de service : kube-vip

vip_interface porte le nom de carte réseau de l'instance de référence, à corriger avant d'appliquer

core/kube-vip/kube-vip-ds.yaml fixe vip_interface à ens34. C'est le nom de la carte réseau des VM de l'instance de référence, pas une valeur universelle : un hyperviseur, une version de Talos ou un modèle de carte différents donnent ens192, eth0 ou autre chose. Aucune variable de la fiche client ne le paramètre et render-config.sh n'y touche pas. Avec un nom qui ne correspond à rien, les pods démarrent, restent Running, et ne publient jamais d'adresse. Relever le nom réel sur un noeud avant d'appliquer, puis corriger le manifeste s'il diffère :

talosctl -n <un-control-plane> get links
La colonne ID de cette sortie porte les noms d'interface du noeud. Le même nom doit être employé dans le bloc vip de la configuration machine Talos, s'il y en a un.

Question ouverte : le compte de service de ce DaemonSet

core/kube-vip/kube-vip-ds.yaml déclare serviceAccountName: kube-vip, mais aucun manifeste de ce dépôt ne crée ce ServiceAccount ni les droits RBAC qui vont avec (lecture et mise à jour des Service, des Endpoints, et des Lease pour l'élection de leader). Si les pods restent en erreur de création faute de ce compte de service, le provisionner à la main avant de poursuivre, avec les droits standard documentés par le projet kube-vip pour son mode service (svc_enable). Non vérifié dans ce dépôt : à confirmer avant une installation client, ou à corriger dans le dépôt en amont de cette procédure.

kubectl apply -f core/kube-vip/
Vérification Symptôme si échec
kubectl get pods -n kube-system -l app.kubernetes.io/name=kube-vip-ds : un pod par noeud control-plane, Running Pods absents ou en erreur de création : le ServiceAccount kube-vip manque, voir la question ouverte ci-dessus
Une adresse réellement annoncée : kubectl get svc -n traefik-ingress traefik porte ${TV_VIP} en EXTERNAL-IP <pending> : les pods tournent mais kube-vip n'annonce rien. Les deux causes sont dans les deux encarts ci-dessus, dans cet ordre de probabilité : vip_interface ne nomme pas l'interface réelle des noeuds, ou le ServiceAccount kube-vip n'a pas les droits de mettre à jour le statut des Service. kubectl logs -n kube-system -l app.kubernetes.io/name=kube-vip-ds distingue les deux

Ce DaemonSet est distinct du kube-vip de Talos : il annonce des adresses pour des services LoadBalancer (la VIP Traefik, le DNS de LAN, le concentrateur WireGuard), et pas l'endpoint du control-plane, que son manifeste désactive explicitement par cp_enable: "false".

Ce que kube-vip fait ici, et ce qu'il ne fait pas

Ce DaemonSet n'attribue aucune adresse. Il est le manager de kube-vip : il annonce en ARP une adresse déjà portée par un Service, il n'en choisit pas. L'attribution serait le rôle de kube-vip-cloud-provider, un Deployment distinct qui lit la ConfigMap kubevip du namespace kube-system ; ce dépôt ne l'installe nulle part, et cette ConfigMap n'est donc lue par personne.

Conséquence pour l'opérateur, et elle vaut au-delà de cette étape : ${TV_VIP_RANGE} est une plage à réserver et à documenter, pas une plage distribuée. Chaque Service LoadBalancer épingle son adresse lui-même, et les trois du dépôt le font déjà, par deux formes différentes : spec.loadBalancerIP pour core/traefik/traefik.yaml, l'annotation kube-vip.io/loadbalancerIPs pour core/lan-dns/lan-dns.yaml, et les deux à la fois pour core/vpn/wireguard-incluster.yaml, qui n'est pas appliqué par cette procédure. Un Service ajouté par la suite n'a besoin que d'une des deux, l'annotation étant la forme que kube-vip documente aujourd'hui, spec.loadBalancerIP étant dépréciée côté Kubernetes. Un Service LoadBalancer ajouté plus tard sans adresse épinglée restera <pending> indéfiniment, et aucun message ne dira pourquoi.

Ce qui se constate donc ici n'est pas une attribution, c'est une annonce, et elle porte sur un Service qui existe déjà : celui de Traefik, posé par la couche de base à l'étape 3, « Poser la couche de base », avec son adresse épinglée. Rien entre cette étape et l'étape 10, « Le reste de la couche réseau, ingress et DNS interne », ne le crée ni ne le modifie, et c'est exactement ce que cette étape-là relit cinq étapes plus loin. La ligne de vérification ci-dessus avance donc de cinq étapes la découverte des deux pannes que les encarts ci-dessus décrivent, et il n'y a aucune raison d'attendre.

Ne pas fabriquer d'autre contrôle ici. Créer un Service LoadBalancer de test, sans adresse épinglée, ne rendrait jamais rien, faute d'attribution : ce serait un symptôme inventé sur une installation saine.

6. Installer et amorcer Vault

Vault existe et ses pods sont Running (étape 4, « Provisionner le stockage »), mais Vault n'a ni contenu ni voisin capable de l'interroger. Trois choses manquent : le contrôleur External Secrets Operator lui-même, l'initialisation de Vault, et sa configuration pour parler à Kubernetes.

6.1. External Secrets Operator

Installer le contrôleur ESO

Aucun manifeste de ce dépôt ne déploie le contrôleur lui-même : il vient du dépôt Helm amont, avec les valeurs du dépôt.

helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm upgrade --install external-secrets external-secrets/external-secrets \
  -n external-secrets --create-namespace --version 2.5.0 \
  -f secrets/eso/external-secrets-values.yaml
installCRDs: true n'est pas facultatif : sans les CRD ExternalSecret et ClusterSecretStore, le chart s'installe et l'apiserver refuse tous les manifestes du dépôt qui les utilisent.

Vérification : kubectl get pods -n external-secrets : les pods du contrôleur Running.

6.2. Initialiser et desceller Vault

Toujours sans Ingress, par port-forward vers le Service interne. Écrire le résultat de l'initialisation hors du clone git : ce fichier contient cinq clés de descellement et un jeton racine, et un git add . un peu large suffit à les verser dans l'historique du dépôt.

kubectl port-forward -n vault svc/vault 8200:8200 &
export VAULT_ADDR=http://127.0.0.1:8200
vault operator init -key-shares=5 -key-threshold=3 > "$HOME/vault-init-${TV_DOMAIN}.txt"   # hors du clone git, jamais commite

Desceller vault-0 avec trois des cinq clés, puis se connecter :

vault operator unseal <cle-1>
vault operator unseal <cle-2>
vault operator unseal <cle-3>
vault login   # avec le jeton racine

L'état de scellement est propre à chaque processus : vault-1 et vault-2 rejoignent le raft grâce au retry_join déjà configuré dans secrets/vault.yaml, mais restent scellés tant qu'on ne le fait pas explicitement, avec les mêmes clés :

kubectl exec -n vault vault-1 -- vault operator unseal <cle-1>
kubectl exec -n vault vault-1 -- vault operator unseal <cle-2>
kubectl exec -n vault vault-1 -- vault operator unseal <cle-3>
# repeter pour vault-2
Vérification Symptôme si échec
vault status (via le port-forward) : Sealed: false, HA Mode: active sur un des trois pods Sealed: true persistant sur un pod : clés de descellement fausses, ou pod redémarré depuis (le scellement revient à chaque redémarrage du processus)

6.3. Monter le coffre et l'authentification Kubernetes

vault secrets enable -path=secrets -version=2 kv
vault auth enable kubernetes

secrets/eso/vault-store.yaml attend un mount KV v2 nommé secrets et une méthode d'authentification Kubernetes montée sur kubernetes : ces deux noms ne sont pas arbitraires, ils doivent correspondre exactement.

Les droits pour valider les jetons Kubernetes

Configurer l'authentification Kubernetes de Vault exige que le compte de service utilisé pour interroger l'API (le jeton de revue, token_reviewer_jwt) porte le droit de créer des TokenReview, par une liaison au ClusterRole system:auth-delegator. secrets/vault.yaml verse cette ClusterRoleBinding pour vault-sa : appliquer le fichier suffit, il n'y a rien à provisionner à la main. Sans elle, vault write auth/kubernetes/config s'accepte sans erreur mais la validation d'un jeton présenté par ESO échoue ensuite en Forbidden. Le symptôme apparaît sur le statut de l'ExternalSecret, jamais sur Vault, ce qui rend la cause difficile à retrouver.

vault write auth/kubernetes/config \
  kubernetes_host="https://kubernetes.default.svc:443"

Créer la politique et le rôle eso que vault-store.yaml référence, restreints en lecture aux chemins prod/* (le préfixe prod est celui que portent tous les ExternalSecret du dépôt, quel que soit l'environnement réel du client, ce n'est pas une valeur à adapter) :

vault policy write eso-read - <<'EOF'
path "secrets/data/prod/*" {
  capabilities = ["read"]
}
path "secrets/metadata/prod/*" {
  capabilities = ["list"]
}
EOF

vault write auth/kubernetes/role/eso \
  bound_service_account_names=external-secrets \
  bound_service_account_namespaces=external-secrets \
  policies=eso-read \
  ttl=1h

6.4. Écrire le secret d'amorçage

Le seul secret strictement nécessaire pour débloquer la suite immédiate de la procédure est le jeton Cloudflare, sur son propre chemin, séparé de tout autre secret :

vault kv put -mount=secrets prod/cloudflare-dns api-token=<TOKEN_CLOUDFLARE>

Le token doit porter Zone:DNS:Edit et Zone:Zone:Read, restreint à la zone ${TV_DOMAIN}. put convient ici parce que ce chemin est dédié à cette seule clé. Sur tout chemin partagé par plusieurs clés, utiliser vault kv patch, jamais put, qui remplacerait l'intégralité du contenu du chemin.

Vérification Symptôme si échec
vault kv get -mount=secrets prod/cloudflare-dns renvoie la clé api-token Chemin absent : la commande précédente a échoué silencieusement, ou le mount secrets n'existe pas encore

7. Fermer la boucle : le ClusterSecretStore

kubectl apply -f secrets/eso/vault-store.yaml
Vérification Symptôme si échec
kubectl get clustersecretstore vault-store : READY=True READY=False : kubectl describe clustersecretstore vault-store porte la cause dans le message de la condition Ready, et les trois causes ne se corrigent pas au même endroit. permission denied sur un chemin : la politique eso-read ou le rôle eso de l'étape précédente. TokenReview ou Forbidden : la liaison RBAC de la question ouverte 6.3. connection refused ou Vault is sealed : Vault s'est rescellé, reprendre l'étape 6.2, « Initialiser et desceller Vault »

C'est ici que la dépendance circulaire se referme dans le bon sens : à partir de cette étape, n'importe quel ExternalSecret du dépôt peut lire Vault, sans qu'aucun Ingress ni certificat n'ait été nécessaire pour y arriver.

8. Matérialiser le jeton Cloudflare dans cert-manager

kubectl apply -f core/cert-manager/cloudflare-dns-token.yaml
Vérification Symptôme si échec
kubectl get externalsecret cloudflare-dns-token -n cert-manager : SecretSynced ; kubectl get secret cloudflare-dns-token -n cert-manager existe SecretSyncedError : le chemin Vault prod/cloudflare-dns est absent ou mal orthographié, ou le ClusterSecretStore n'est pas encore Ready

Attendre que le Secret existe avant de continuer : les ClusterIssuer de l'étape suivante le référencent par son nom, pas par une dépendance Kubernetes explicite, rien ne les empêche de démarrer avant et d'échouer silencieusement à résoudre le challenge DNS-01.

9. Émettre les certificats

kubectl apply -f certs/
kubectl get certificate -A
Vérification Symptôme si échec
kubectl get certificate -A : READY=True sur le certificat wildcard READY=False persistant : token Cloudflare absent ou mal scopé (étape 6.4, « Écrire le secret d'amorçage »), ou les deux arguments DNS-01 manquants sur cert-manager (étape 3, « Poser la couche de base »). kubectl describe certificate wildcard-tradeval-app -n traefik-ingress détaille le challenge en échec

Point de non-retour : ne pas enchaîner sur la suite tant que ce n'est pas Ready

Si ${TV_DOMAIN} se termine en .app, le domaine est sur la liste HSTS preload des navigateurs. Tant que ce certificat n'est pas Ready, aucun host de la plateforme n'est joignable en HTTPS, et aucun contournement navigateur n'existe : le bouton « continuer malgré tout » n'existe pas sur ce type de domaine. Rien ne sert d'appliquer la suite de la procédure avant que cette ligne ne passe à True.

10. Le reste de la couche réseau, ingress et DNS interne

kubectl apply -f core/traefik/tlsstore-default.yaml
kubectl apply -f core/traefik/traefik-rbac.yaml
kubectl apply -f core/traefik/traefik-ingressclass.yaml
kubectl apply -f core/coredns/coredns-configmap.yaml
kubectl rollout restart deployment/coredns -n kube-system
kubectl apply -f core/lan-dns/
kubectl apply -f core/metrics-server/metrics-server.yaml
kubectl apply -f core/platform-access/longhorn.yaml \
              -f core/platform-access/vault.yaml

traefik-rbac.yaml et traefik-ingressclass.yaml ne sont couverts ni par la couche de base ni par aucune autre commande de cette procédure : sans le premier, Traefik tourne mais ne peut lire aucun Ingress, faute des droits de lister cette ressource, et ne sert donc aucune route, sans qu'aucune erreur ne l'affiche autrement que par la couche 4 des évènements du pod.

core/platform-access/ est appliqué par fichiers nommés, et non en bloc : sur ses cinq fichiers, grafana.yaml, prometheus.yaml et alertmanager.yaml portent namespace: monitoring. Aucun manifeste de ce dépôt ne crée ce namespace, et aucune page de ce guide n'installe la stack qui le porte (Observabilité). Appliqué en bloc, le répertoire rend trois fois Error from server (NotFound): namespaces "monitoring" not found et kubectl sort en 1, après avoir tout de même appliqué les deux autres fichiers : l'opérateur voit trois erreurs, croit l'étape ratée, et rejoue une commande qui échouera à l'identique. Les deux fichiers nommés ci-dessus visent des namespaces qui existent à ce stade, longhorn-system, créé par le helm install de l'étape 4, « Provisionner le stockage », et vault, créé par la couche de base. Les trois Ingress de la stack monitoring s'appliquent une fois cette stack installée, ce que ce guide ne couvre pas.

Vérification Symptôme si échec
kubectl get ingress -n longhorn-system et kubectl get ingress -n vault : chacun rend un Ingress, longhorn.${TV_DOMAIN} et vault.${TV_DOMAIN} No resources found sur l'un des deux : l'étape 4, « Provisionner le stockage », n'a pas créé le namespace longhorn-system, ou la commande ci-dessus n'a pas été jouée sur les deux fichiers nommés. Un kubectl apply -f core/platform-access/ en bloc n'est pas la cause : il pose ces deux Ingress avant d'échouer sur les trois autres
kubectl get svc -n traefik-ingress : EXTERNAL-IP égale ${TV_VIP} <pending> : la VIP n'est pas dans la plage kube-vip, ou l'étape 5, « Publier la VIP de service : kube-vip », n'a pas abouti
Une route réellement servie : curl -o /dev/null -w '%{http_code}\n' https://vault.${TV_DOMAIN}/ depuis un poste qui résout ce host vers la VIP : 200, 302 ou 307 EXTERNAL-IP correcte mais connexion refusée, ou toujours le certificat par défaut de Traefik : traefik-rbac.yaml n'a pas été appliqué, Traefik ne voit aucun Ingress
Depuis un pod glibc, sans hostAliases : getent hosts hub.${TV_DOMAIN} répond une adresse A, celle de ${TV_TRAEFIK_CLUSTERIP} Pas de réponse, ou une chaîne CNAME que glibc ne suit pas : la règle CoreDNS n'a pas encore été appliquée, ne respecte pas le format answer auto, ou a déjà été écrasée par Talos (voir l'avertissement plus bas)
kubectl get apiservice v1beta1.metrics.k8s.io : AVAILABLE=True, puis kubectl top nodes rend des chiffres pour tous les noeuds NotFound, ou AVAILABLE=False, ou un kubectl top nodes muet : metrics-server a été appliqué sans servir de métriques. Le kubectl apply a réussi, ce n'est pas ce qui se constate ici. Diagnostic à Troubleshooting, entrée 13

Le résolveur de LAN relaie vers deux résolveurs publics codés en dur

core/lan-dns/lan-dns.yaml termine son Corefile par forward . 1.1.1.1 8.8.8.8. Ce résolveur sert le LAN et le VPN, et ces deux adresses sont son unique amont pour tout ce qui n'est pas *.${TV_DOMAIN}. Aucune variable de la fiche ne les paramètre. Deux conséquences à trancher avec le client : si son réseau filtre le port 53 sortant, ce résolveur ne répond plus pour le reste d'internet, et les postes qui le prennent comme DNS principal perdent toute résolution ; et si sa politique impose ses propres résolveurs, cette ligne est à remplacer à la main dans le manifeste. C'est la même contrainte de sortie sur le port 53 que les deux résolveurs DNS-01 de cert-manager, et elle se valide au même moment (Prérequis).

Talos réécrivait CoreDNS : réglé le 2026-09-10, mais l'ordre compte

La ConfigMap coredns portait l'annotation config.k8s.io/owning-inventory, que Talos considère comme sienne : un redémarrage de control-plane, une mise à niveau Talos ou un talosctl apply-config pouvaient l'écraser et faire disparaître la résolution de *.${TV_DOMAIN} sans avertissement. Talos ne pose plus CoreDNS : talos/patches/coredns-disabled.yaml porte cluster.coreDNS.disabled: true, et core/coredns/coredns-stack.yaml prend la relève. La procédure complète est dans core/coredns/README.md. Un point à ne pas inverser sur une nouvelle installation : retirer l'annotation d'inventaire des six objets AVANT de poser le patch Talos. Talos élague d'après cette annotation ; dans cet ordre la bascule ne coupe rien, et dans l'autre elle supprime le Service kube-dns, donc le DNS de tout le cluster, jusqu'à ce qu'il soit recréé avec la même ClusterIP.

Cette étape posait aussi, jusqu'au 2026-09-10, deux Bundles trust-manager qui distribuaient un CA interne aux quatre zones. Le CA a été retiré : *.${TV_DOMAIN} est signé par Let's Encrypt, dont la chaîne est déjà dans le magasin par défaut de chaque image. Il n'y a donc plus rien à distribuer, et rien à monter dans les charges.

Un manifeste est délibérément tenu hors du bloc ci-dessus : core/monitoring/node-exporter.yaml, la source des métriques d'hôte de la page Observabilité. La couche de base ne le pose pas, et il n'appartient à aucune commande héritée de la procédure : cette étape l'ajoute, ci-dessous, parce que c'est le moment logique, en même temps que metrics-server. Sans lui, les métriques d'hôte, CPU, mémoire et disque des noeuds, ne remontent pas, et rien ne le signale : le tableau tradeval-ressources affiche simplement des séries vides.

kubectl apply -f core/monitoring/node-exporter.yaml
Vérification Symptôme si échec
kubectl get pods -n node-exporter : un pod par noeud, Running Namespace inexistant : la commande ci-dessus n'a pas été jouée, et aucune autre de la procédure ne pose ce manifeste

metrics-server réveille les dix HPA du dépôt d'un coup

Cette ligne du bloc ci-dessus n'est pas anodine. Tant que metrics-server n'est pas là, les dix HPA du dépôt affichent <unknown> et ne font rien ; dès qu'il sert des métriques, ils s'exécutent tous, avec la configuration telle qu'elle est écrite et sans période de rodage. Sur l'instance de référence, cette bascule a saturé le CPU du cluster en quelques minutes. Les valeurs livrées portent le correctif de cet incident (maxReplicas: 3, 6 pour hub-portail, métrique cpu seule), mais elles ont été calibrées sur un cluster de trois workers à 4 vCPU. Avant d'appliquer ce manifeste sur un dimensionnement différent, confronter les requests et les maxReplicas à l'usage réel, comme le demande Observabilité. Lecture du résultat :

kubectl get hpa -A -o custom-columns=\
'NS:.metadata.namespace,NOM:.metadata.name,MAX:.spec.maxReplicas,METRIQUES:.spec.metrics[*].resource.name'
Attendu : MAX à 3 partout sauf hub-portail à 6, et METRIQUES à cpu seul. Une métrique memory ou un MAX très supérieur est une divergence, pas l'état livré, et ne doit pas rencontrer metrics-server.

11. Couche de données

Les six endpoints externes (base applicative, Kafka, Redis, Elasticsearch, MinIO, SMTP) ont été provisionnés et vérifiés joignables à la page Prérequis. Cette étape écrit leurs coordonnées dans Vault, sur les chemins prod/<service> que chaque ExternalSecret applicatif consommera à l'étape 13, « Applications et exposition » :

vault kv patch -mount=secrets prod/<service> CLE=valeur

patch, jamais put : ces chemins portent plusieurs clés, et put effacerait celles qui n'y figurent pas dans la commande.

Le SMTP est consommé par trois émetteurs, et les trois se configurent séparément : trade-notification-svc lit SMTP__HOST, SMTP__PORT, SMTP__USESSL, SMTP__USERNAME, SMTP__PASSWORD et SMTP__FROMEMAIL dans prod/notification ; Alertmanager lit son mot de passe dans prod/alertmanager, clé smtp_password, le reste venant du fichier de valeurs (page Observabilité) ; Keycloak se règle dans le realm, à l'étape 14, « Keycloak et son realm ». Un fournisseur transactionnel (Resend, SES, SendGrid) n'accepte que le domaine d'expéditeur EXACT qu'il a vérifié : noreply@notifications.${TV_DOMAIN} passe si c'est notifications.${TV_DOMAIN} qui porte les enregistrements DKIM et SPF, et noreply@${TV_DOMAIN} est alors refusé en 550, à l'envoi seulement, après une authentification réussie.

Vérification Symptôme si échec
vault kv get -mount=secrets prod/<service> renvoie bien les clés qui viennent d'être écrites, pour chaque service concerné Chemin absent ou clé manquante à la relecture : la commande de la ligne précédente a échoué ou visait le mauvais chemin. Ce contrôle se fait sur place, sans attendre que les pods applicatifs existent

Question ouverte : le nom exact des clés attendues par chaque service

Ce dépôt verse les ExternalSecret (le nom du chemin Vault, le nom du Secret Kubernetes produit), mais pas la liste des variables que chaque microservice lit dans ce Secret : ces noms vivent dans le code applicatif, hors du périmètre de ce dépôt. Confirmer auprès de l'éditeur les clés exactes attendues par chaque service avant d'écrire dans Vault : une clé absente reproduit le symptôme déjà documenté sur CONTENT_SERVICE_URL, un service qui démarre normalement et échoue seulement à l'usage.

Question ouverte : la base de données de Keycloak

apps/keycloak/keycloak.yaml code en dur jdbc:postgresql://keycloak-db-rw:5432/app comme KC_DB_URL. Ce Service n'est créé par aucun manifeste de ce dépôt, et ce septième endpoint n'apparaît pas parmi les six variables de données externes de la fiche client. Sans lui, Keycloak ne démarre jamais à l'étape 13, « Applications et exposition », et le trou n'apparaît qu'à l'étape 14, « Keycloak et son realm », en essayant de joindre un realm qui n'existe pas. Provisionner une base Postgres joignable sous ce nom et ce port avant cette étape-là (par un Service Kubernetes qui porte ce nom exact si la base vit hors du cluster, ou par l'opérateur qui l'exploite déjà, selon ce que le client fournit), puis écrire ses identifiants dans Vault, au chemin que l'ExternalSecret keycloak-db-external-secret attend :

vault kv put -mount=secrets prod/keycloak-db-secret username=<user> password=<mot-de-passe>

12. Images et secrets de registre

${TV_REGISTRY} porte déjà les onze images applicatives, publiées avec leurs noms et leurs tags exacts (page Paramètres). Onze manifestes de ce dépôt, les sept API, les trois portails et Keycloak, référencent un imagePullSecrets nommé acr-pull-secret. Aucun manifeste ne le crée : sans lui, les onze pods partent en ImagePullBackOff dès l'étape suivante, quelle que soit la justesse du registre et des tags.

Un Secret est un objet de namespace, et les workloads sont répartis sur quatre zones. Il faut donc le créer quatre fois, avec exactement le même contenu. C'est la seule étape du guide où l'oubli d'une zone se voit à retardement : les trois autres démarrent normalement, et seule la quatrième part en ImagePullBackOff.

Question ouverte : cette étape n'a jamais été jouée contre un registre client

Le tirage décrit plus bas, la table des chaînes d'erreur et la compatibilité annoncée de --for=jsonpath sont raisonnés à partir des manifestes de ce dépôt et du comportement documenté de kubectl, pas observés sur un registre réel. Non vérifié dans ce dépôt : à confirmer avant une installation client. Un message de registre que la table ne prévoit pas ne dit donc pas que l'installation est anormale, il dit que la table est incomplète. Dans ce cas, la ligne Pulled reste le seul critère qui conclut, et le texte exact du message se rapporte à l'éditeur plutôt que d'être interprété sur place.

for z in web internal api auth; do
  kubectl create secret docker-registry acr-pull-secret -n "$z" \
    --docker-server=${TV_REGISTRY} \
    --docker-username=<utilisateur-registre> \
    --docker-password=<mot-de-passe-ou-jeton-registre>
done
Vérification Symptôme si échec
Les évènements du pod jetable ci-dessous portent une ligne Pulled nommant l'image tirée de ${TV_REGISTRY} Une ligne Failed to pull image : unauthorized ou denied, l'utilisateur ou le mot de passe ne conviennent pas ; not found ou manifest unknown, l'image n'a pas été publiée sur ${TV_REGISTRY} ou pas sous ce tag ; no basic auth credentials, le Secret n'a pas été attaché, soit qu'il manque dans la zone, soit que l'--overrides ait été mal recopié. Aucune ligne Pulling du tout, avec un pod Pending : ce pod n'a pas été ordonnancé, et le registre n'est pas en cause

Que le Secret existe ne dit rien de ce qu'il contient : un --docker-server mal saisi, un mot de passe périmé ou un jeton sans droit de lecture produisent tous un Secret parfaitement bien formé, et onze ImagePullBackOff une étape plus loin. Le seul contrôle qui conclut est un tirage :

kubectl run acr-pull-check -n api --restart=Never \
  --image="${TV_REGISTRY}/trade-hub-ui-prod:latest" \
  --overrides='{"spec":{"imagePullSecrets":[{"name":"acr-pull-secret"}]}}'
kubectl wait --for=jsonpath='{.status.phase}'=Running pod/acr-pull-check -n api --timeout=180s
kubectl describe pod acr-pull-check -n api | sed -n '/Events/,$p'
kubectl delete pod acr-pull-check -n api --ignore-not-found

Trois remarques sur la forme de ces commandes, chacune pour une raison précise.

--overrides n'est pas décoratif : kubectl run n'attache aucun imagePullSecrets de lui-même, et sans lui le tirage emprunterait un tout autre chemin que celui des onze workloads.

C'est le describe qui répond, pas le wait. Le wait ne fait que borner l'attente : il sort en timed out waiting for the condition aussi bien sur un tirage refusé que sur un conteneur qui a bien été tiré mais ne démarre pas. Son code de sortie ne distingue donc pas les deux, et les messages du registre ne vivent que dans les évènements du pod. Lancer le describe dans tous les cas, y compris quand le wait réussit.

Une liste d'évènements vide ne se lit pas dans la table ci-dessus : elle veut dire que le describe est arrivé trop tôt, avant que le kubelet n'ait écrit quoi que ce soit. C'est le cas si le wait a échoué immédiatement, par exemple parce que la version de kubectl du poste refuse la syntaxe --for=jsonpath (acceptée depuis la 1.23). Relancer le seul describe après quelques secondes, avant de conclure quoi que ce soit ; ne pas la lire comme l'absence de ligne Pulling, qui est un diagnostic et non un silence.

Aucun entrypoint n'est remplacé, délibérément : les onze images n'embarquent pas toutes un shell ni la même commande, et un --command impose de deviner. La contrepartie est que le conteneur démarre pour de bon, sans son Secret de configuration : il sortira vraisemblablement en erreur. Sous --restart=Never cela donne un pod en phase Failed, conteneur Terminated, motif Error ; jamais un CrashLoopBackOff, qui suppose une politique de redémarrage que ce pod n'a pas. CreateContainerError reste possible, si l'image ne porte pas d'entrypoint utilisable. Ces états sont sans importance ici ; ils arrivent après la ligne Pulled, et c'est cette ligne, elle seule, que cette étape constate. Remplacer la référence d'image par n'importe laquelle des onze publiées sur ${TV_REGISTRY}, avec son tag exact ; les tags sont hétérogènes et une image n'en porte aucun (Paramètres). Supprimer le pod dans tous les cas : il n'a pas de --rm, et il n'en a pas parce qu'un pod qui n'a jamais tiré son image ne serait de toute façon pas supprimé par ce drapeau.

13. Applications et exposition

kubectl apply -R -f apps/api/ -f apps/keycloak/ -f apps/web/
kubectl apply -f core/cloudflare/namespace.yaml

Les trois répertoires nommés ci-dessus posent les onze workloads applicatifs, et eux seuls. apps/ n'est pas visé en bloc, et ce n'est pas un raccourci d'écriture : apps/mailpit/ y range un collecteur de courriels réservé aux recettes, qui capture ce que la plateforme envoie et n'en délivre rien. Il ne s'applique jamais chez un client : la plateforme envoie par le SMTP du client dès le premier démarrage, avec les coordonnées TV_SMTP_HOST écrites dans Vault à l'étape 11, « Couche de données ». Ce point se clôt avec le client avant la mise en service (Exploitation).

core/cloudflare/namespace.yaml s'applique seul, et avant le reste de son répertoire. kubectl apply -f core/cloudflare/ visite les fichiers en ordre lexical, ce qui place namespace.yaml en dernier, après les trois objets qui vivent dedans : sur une installation neuve, la commande rend trois fois namespaces "cloudflare" not found et kubectl sort en 1. Les trois autres fichiers s'appliquent plus bas, une fois le tunnel créé et ses identifiants écrits dans Vault, faute de quoi l'ExternalSecret n'a rien à lire et les pods cloudflared restent en ContainerCreating.

Le VPN n'est pas posé par cette procédure, et ce n'est pas un oubli

core/vpn/wireguard-incluster.yaml existe et pourrait tenter : ne l'appliquez pas. Ses quatre premières lignes portent Non appliqué et option B ; option A = VM passerelle. L'option retenue par ce dépôt est la VM passerelle, hors cluster, qui laisse le cluster sans aucun port entrant, dans le même sens que le choix du tunnel Cloudflare. Ce guide ne la décrit pas : il la nomme, et s'arrête là.

Appliqué quand même, ce manifeste ne démarre pas. Il monte un Secret wireguard-config qu'aucun manifeste appliqué ne crée : il n'existe que dans wg-server-secret.example.yaml et dans externalsecret-wireguard.example.yaml, tous deux des exemples. Le pod reste en ContainerCreating indéfiniment. Un opérateur qui choisit malgré tout l'option B fournit ce Secret lui-même, en suivant core/vpn/README.md, et sort alors de cette procédure.

Ne jamais appliquer un répertoire entier sans en vérifier le contenu

Un fichier dont le nom porte example ou template avant son extension contient des valeurs de remplacement, par convention de ce dépôt : il n'est jamais destiné à être appliqué tel quel. core/vpn/ contient deux fichiers de ce genre, wg-server-secret.example.yaml et externalsecret-wireguard.example.yaml. kubectl apply -f core/vpn/ sans nommer le fichier les applique aussi : l'opérateur se retrouve avec un Secret WireGuard à fausses clés, ou un ExternalSecret pointé sur un chemin Vault qui n'existe pas, sans qu'aucune erreur ne le signale. C'est une raison de plus de ne pas viser core/vpn/ du tout à cette étape. Le même risque vaut pour toute autre commande de cette page qui applique un répertoire entier : des répertoires visés ailleurs dans cette procédure, seuls certs/, core/kube-vip/ et core/lan-dns/ sont appliqués en bloc, et aucun des trois ne contient de fichier de ce genre ; core/platform-access/, core/cloudflare/ et les sous-répertoires d'apps/ sont appliqués par fichiers ou par répertoires nommés. Ce constat date de l'écriture de cette page ; une commande le refait en une fois, sur le dépôt tel qu'il est :

find apps certs secrets/eso core/kube-vip core/lan-dns core/platform-access \
     core/cloudflare core/vpn \
     \( -name '*example*.yaml' -o -name '*template*.conf' \)
Attendu : trois lignes, les trois fichiers de core/vpn/. Le motif porte sur example et template où qu'ils soient dans le nom, et pas seulement sur le suffixe *.example.yaml : un quatrième fichier, switchover-example.yaml, sortait de ce suffixe et échappait au contrôle, qui concluait « rien d'autre » en n'ayant vérifié qu'une convention de nommage. Ce fichier a disparu avec le retrait du CA interne, le motif large reste. Toute autre ligne nomme un répertoire qu'il ne faut plus appliquer en bloc.

Le tunnel Cloudflare se crée par API, jamais par cloudflared tunnel login : cette commande dépose sur la machine un certificat d'origine qui donne les pleins pouvoirs sur la zone. Les détails, permissions de jeton, règles WAF du edge et enregistrements DNS, sont dans core/cloudflare/README.md. Trois gestes précèdent l'application des trois fichiers restants, et leur ordre compte :

  1. Créer le tunnel par API, sous le nom ${TV_CF_TUNNEL_NAME} de la fiche client, avec un jeton portant la permission Account -> Cloudflare One Connector: cloudflared -> Edit, à révoquer juste après. La réponse porte l'UUID du tunnel et un champ credentials_file utilisable tel quel.
  2. Écrire ces identifiants dans Vault, au chemin prod/cloudflared que lit core/cloudflare/externalsecret-cloudflared.yaml : vault kv put -mount=secrets prod/cloudflared credentials.json=@credentials.json.
  3. Reporter l'UUID à la main dans core/cloudflare/cloudflared-config.yaml, à la clé tunnel, qui porte encore l'UUID du tunnel d'origine : aucune substitution ne le touche (Paramètres).

Ensuite seulement, les trois fichiers restants du répertoire :

kubectl apply -f core/cloudflare/externalsecret-cloudflared.yaml
kubectl apply -f core/cloudflare/cloudflared-config.yaml
kubectl apply -f core/cloudflare/cloudflared-deployment.yaml

Les trois enregistrements DNS se publient en dernier, une fois les règles du edge en place : dès que le DNS existe, le service est joignable depuis internet.

Vérification Symptôme si échec
kubectl get pods -n internal : les onze workloads applicatifs sont là, et aucun n'est en CrashLoopBackOff ni en ImagePullBackOff. Ce namespace était vide jusqu'ici (étape 3, « Poser la couche de base ») : tout pod en échec vient donc de cette commande ImagePullBackOff : acr-pull-secret manquant (étape 12, « Images et secrets de registre »), image absente du registre ${TV_REGISTRY}, ou tag qui ne correspond pas à ce qui a été publié. Douze workloads au lieu de onze : apps/ a été appliqué en bloc et apps/mailpit/ est passé avec
kubectl get ns cloudflare existe, et kubectl get externalsecret cloudflared-credentials -n cloudflare : SecretSynced namespaces "cloudflare" not found : le répertoire a été appliqué en bloc et l'ordre lexical a posé le namespace en dernier. SecretSyncedError : le chemin prod/cloudflared n'a pas été écrit dans Vault (geste 2 ci-dessus), ou le rôle Vault eso n'autorise pas ce chemin
kubectl get deploy cloudflared -n cloudflare : READY 2/2, et kubectl -n cloudflare logs deploy/cloudflared contient Registered tunnel connection Pods en ContainerCreating : le Secret cloudflared-credentials n'existe pas, le volume ne se monte pas. Pods Running sans cette ligne : l'UUID du tunnel n'a pas été reporté dans cloudflared-config.yaml (geste 3), et les pods s'attachent au tunnel d'origine, ou la sortie HTTPS vers le réseau Cloudflare est filtrée

Trois hosts publics, pas un de plus

Ne publier que trois enregistrements DNS : hub, portal et auth, et n'ouvrir que les routes de tunnel correspondantes. Depuis l'abandon du TLD interne, le cloisonnement entre ce qui est public et ce qui est interne ne repose plus que sur cette discipline d'exploitation. Un enregistrement publié pour un host interne l'expose à internet, sans autre garde-fou automatique que le contrôle 8 de scripts/validate-install.sh.

14. Keycloak et son realm

Le pod Keycloak vient de démarrer, à l'étape précédente. La création du realm n'est pas automatisée dans ce dépôt : elle se fait par la console d'administration, à https://auth.${TV_DOMAIN}.

Aucun compte administrateur n'existe encore, et les journaux ne renseignent rien

Ce déploiement Keycloak ne fixe ni KEYCLOAK_ADMIN ni KC_BOOTSTRAP_ADMIN_*. Sur les versions récentes de Keycloak, l'absence de ces variables ne produit aucun compte temporaire et n'écrit aucun identifiant dans les journaux du pod : sans l'étape ci-dessous, la console d'administration reste inaccessible et la création du realm est bloquée, pas seulement gênée.

kubectl exec -it -n auth deploy/keycloak -- /opt/keycloak/bin/kc.sh bootstrap-admin user
La commande demande un identifiant et un mot de passe de façon interactive. Les conserver au même endroit que les clés Vault de l'étape 6.2, « Initialiser et desceller Vault », jamais dans le dépôt.

Une fois connecté : créer le realm ${TV_KC_REALM}, les clients de chaque application, leurs Valid Redirect URIs et leurs Web Origins. Puis le courrier du realm, Realm settings > Email : sans lui, la réinitialisation de mot de passe et la vérification d'adresse échouent en silence. Host et identifiants sont ceux de TV_SMTP_HOST, l'expéditeur est sur le domaine vérifié par le fournisseur (étape 11, « Couche de données »). Sur le port 465, cocher « Enable SSL » et laisser « Enable StartTLS » décoché ; sur le 587, l'inverse. Les deux cochés ensemble font échouer la connexion : la session est déjà chiffrée quand Keycloak tente le STARTTLS. Le bouton « Test connection » envoie à l'adresse de l'utilisateur connecté, qui doit donc en avoir une réelle ; un échec 550 ... not authorized to send emails from ... désigne l'expéditeur, pas le mot de passe.

Vérification Symptôme si échec
curl -sS https://auth.${TV_DOMAIN}/realms/${TV_KC_REALM}/.well-known/openid-configuration : le champ issuer vaut exactement https://auth.${TV_DOMAIN}/realms/${TV_KC_REALM} Champ issuer différent de l'attendu : KC_HOSTNAME ne porte pas le domaine de la fiche, soit parce que la fiche appliquée à l'étape 2, « Appliquer la fiche client aux manifests », en portait un autre, soit parce que Keycloak a été réappliqué depuis un manifeste non rendu ; reprendre l'étape 2, « Appliquer la fiche client aux manifests », puis réappliquer Keycloak comme à l'étape 13, « Applications et exposition ». Champ issuer absent de la réponse : le realm ${TV_KC_REALM} n'existe pas encore, ou son nom diffère de la fiche

Cette vérification ne couvre que le chemin public

Le curl ci-dessus part du poste de l'opérateur et passe donc par Traefik, qui ajoute X-Forwarded-Proto: https. Il lit l'issuer annoncé sur ce chemin, rien de plus : un champ issuer correct ne dit pas que le chemin interne, celui qu'empruntent les services .NET pour valider les jetons, annonce la même valeur. Le défaut où les deux chemins annoncent deux issuers différents passe donc cette vérification, exactement comme il passe le contrôle 6 de la recette de conformité, et se voit ailleurs : IDX10205 dans les journaux et 401 sur tous les appels d'API. Le seul test qui conclut compare les deux chemins, il est décrit à l'entrée 4 du Troubleshooting.

Vérification de bout en bout

scripts/validate-install.sh --env scripts/install.env

Chaque ligne KO correspond à l'un des contrôles détaillés ci-dessus, dans l'ordre : noeuds prêts, certificat émis, Traefik sur la VIP, résolution CoreDNS, ExternalSecret synchronisés, issuer OIDC, hosts publics répondant, hosts internes non résolus publiquement. Une ligne KO renvoie à l'étape de cette page qui la produit, pas à une réparation générique.