Build a Talos Linux cluster (immutable, API-driven Kubernetes) on VirtualBox with
vagrant up plus one script. Single control plane or HA with 3 CPs and a VIP, then a
full application layer (Cilium, Envoy Gateway, Longhorn, Vault, PostgreSQL…).
Talos has no SSH and no package manager: the OS is immutable and driven entirely through
the talosctl API from the host. Vagrant therefore only creates and starts the VMs; all the
cluster configuration goes through talosctl.
The whole path, in three commands:
vagrantup# creates the VMs, they boot into maintenance mode
./talos/cluster-up.sh# config + etcd bootstrap + kubeconfig + health
./_k8s/platform-up.sh# application layer (assumes CNI=cilium, the default, see §9)
💡 Keep talosctl aligned with TALOS_VERSION. The binary version is what decides the
generated configuration schema; a mismatch with the ISO produces obscure errors.
To pin talosctl to a specific version instead of taking the latest:
ℹ️ The Talos ISO (metal-amd64.iso) is downloaded automatically on the first
vagrant up, into iso/. No Vagrant box or plugin to install: the "dummy communicator"
(no SSH) and the empty pace/empty box are handled by the Vagrantfile.
⚠️ VirtualBox and KVM cannot share VT-x. If the KVM module is loaded, vagrant up dies on
VERR_VMX_IN_VMX_ROOT_MODE — unload it first:
TROUBLESHOOTING.md.
🗺️ 2. IP plan (host-only network 192.168.56.0/24)#
Item
IP
Host (host-only)
192.168.56.1
Kubernetes API VIP
192.168.56.5
talos-cp1 / cp2 / cp3
192.168.56.10 / .20 / .30
talos-w1 / w2 / w3 …
192.168.56.101 / .102 / .103 …
LoadBalancer VIP (Cilium L2)
192.168.56.200
The IPs are deterministic: each VM has a fixed MAC and a DHCP reservation on the
VirtualBox host-only network, created automatically by the Vagrantfile.
Every VM has 2 NICs: NIC1 = VirtualBox NAT (Internet) and NIC2 = host-only192.168.56.x (cluster and API network).
ℹ️ Interface naming: since Talos 1.5, NICs get predictable names (enp0s3,
enp0s8…), not eth0/eth1. The host-only NIC is therefore enp0s8 (VirtualBox NIC2
= PCI bus 0000:00:08.0). The patches never target by name: the VIP is set through
busPath and the node IP through the 192.168.56.0/24 subnet → robust whatever the naming
scheme.
⚠️ The subnet is only half configurable.NETWORK drives the Vagrantfile and
cluster-up.sh, but 192.168.56.x is hard-coded in talos/patch-all.yaml
(validSubnets), talos/patch-cp.yaml (vip.ip, advertisedSubnets) and
talos/cni-flannel.yaml (--iface-can-reach). Changing NETWORK without editing those
three files produces a silently broken cluster.
The topology lives in lab.env, the single source read by the Vagrantfileand by
talos/cluster-up.sh. Start from the versioned template (lab.env is gitignored):
cplab.env.examplelab.env
Variable
Template default
Purpose
TALOS_VERSION
v1.13.7
boot ISO and installer image
INSTALLER_IMAGE
Image Factory image
installer with extensions (iscsi for Longhorn)
CONTROL_PLANES
3
1 = single, 3 = HA with a VIP
WORKERS
3
number of workers
CP_MEM / CP_CPU
4096 / 2
control plane resources (never below 3072: etcd)
WK_MEM / WK_CPU
2048 / 2
worker resources
CNI
cilium
cilium, calico, flannel or none (see §9)
LAB_DOMAIN
talos.lab.example.io
UI domain (*.<domain>: wildcard TLS + HTTPRoute)
SELF_SIGNED
true
TLS mode: true = wildcard signed by a local CA (openssl, no domain, no token), false = cert-manager + Let's Encrypt
LAB_DNS_ZONE
(empty → last 2 labels)
DNS zone of the ACME DNS-01 solver — SELF_SIGNED=false only
LAB_ACME_EMAIL
(empty → admin@<zone>)
Let's Encrypt account (expiry notices) — SELF_SIGNED=false only
LAB_ACME_ISSUER
staging
ACME issuer: staging (untrusted, huge quota) or prod (trusted, 5 certs/week) — SELF_SIGNED=false only
CLOUDFLARE_API_TOKEN
(empty)
cert-manager DNS-01 (_k8s/) — SELF_SIGNED=false only
NETWORK
192.168.56
host-only network
CP_IP_START / CP_IP_STEP
10 / 10
→ .10, .20, .30
WK_IP_START / WK_IP_STEP
101 / 1
→ .101, .102, .103
LB_POOL_START / LB_POOL_END
192.168.56.200 / .230
LoadBalancer IP range; the 1st one is the Gateway's, the wildcard DNS target
Variables read by cluster-up.sh but missing from the template (all have a default):
VIP ($NETWORK.5), CLUSTER_NAME (talos-lab), INSTALL_DISK (/dev/sda),
OUT (_out), FORCE.
💡 Create lab.env anyway. Without it, the Vagrantfile and cluster-up.sh fall back
to their internal defaults — both aligned on v1.13.7 and on CNI=cilium, but you lose the
Image Factory installer image (iscsi extensions), and therefore Longhorn. Keep the CNI
value in lab.env in sync with what you actually want: cluster-up.sh decides what Talos
lays down, platform-up.sh decides what Helm installs afterwards, and two disagreeing
values give you two competing CNIs — a broken pod network.
⚠️ Do not lower CP_MEM below 3072. 2 GB control planes starve etcd as soon as the
_k8s/ addons stack up, and the cluster collapses under load — observability/ requires
4 GB explicitly. That is why the template ships 4096.
💰 What the default topology costs: 3 × 4 GB + 3 × 2 GB = 18 GB of RAM, 12 vCPU and
~6 × 20 GB of disk. A 16 GB host cannot run it — use the minimal lab below.
💡 Minimal lab (2 VMs, ~6 GB). Enough for Talos itself and the base platform
(platform-up.sh); the data addons expect the default 3 workers (Longhorn replicates ×3,
observability/ wants 4 GB control planes). Edit lab.env:
CONTROL_PLANES=1WORKERS=1
⚠️ Edit the file, do not just export the variable.CONTROL_PLANES=1 vagrant up only
affects vagrant: cluster-up.sh re-reads lab.env and would wait for control planes
.20/.30 that were never created. To override on the fly, pass the variable to both
commands: CONTROL_PLANES=1 vagrant up && CONTROL_PLANES=1 ./talos/cluster-up.sh.
🌐 LAB_DOMAIN: the repo is public, so its default is neutral
(talos.lab.example.io). The _k8s/ manifests carry that domain; the *-up.sh scripts
replace it on the fly with LAB_DOMAIN (sed), without ever rewriting the versioned
files. Put your domain in lab.env (see _k8s/README.md).
The 1st control plane is always talos-cp1 (192.168.56.10). The VirtualBox/Vagrant VM name
is identical to the Talos hostname (see §8).
vagrantup# the VMs boot from the ISO, in maintenance mode
./talos/cluster-up.sh# everything else
cluster-up.sh chains: config generation → apply to the nodes (with deterministic
hostnames) → etcd bootstrap → kubeconfig → health wait. It prints the exports you need and
a final kubectl get nodes.
For another topology, edit lab.env or override on the spot:
CONTROL_PLANES=1WORKERS=2./talos/cluster-up.sh# singleCNI=flannel./talos/cluster-up.sh# CNI laid down by Talos, no LB IPs
⚠️ NEVER re-run cluster-up.sh on an already-installed cluster. Its maintenance mode
wait polls the nodes with --insecure; an already-installed node (secure mode) never
answers. The wait is bounded (WAIT_MAINTENANCE, 300 s) and then fails with an explicit
message — but it still wasted five minutes and applied nothing. To grow a running cluster,
see §6.1.
⚠️ NEVER regenerate _out/ (nor FORCE=1) on a running cluster: gen config produces
new secrets and new CAs, which breaks the existing cluster. Do it only after a
vagrant destroy.
🔍 Understanding: the 6 steps by hand (what the script automates)
Useful to learn, to debug, or to resume halfway. The generation command is strictly the
script's own: --install-image included.
Produces _out/controlplane.yaml, _out/worker.yaml and _out/talosconfig. The
kube-apiserver endpoint is the VIP192.168.56.5, in single as in HA.
⚠️ --install-image is not optional. Without it you install the classic installer,
without the system extensions — and Longhorn fails later on iscsiadm: not found. The
value comes from INSTALLER_IMAGE (lab.env).
⚠️ The CNI patch is not optional either. One file per intent —
cni-cilium.yaml (the default), cni-calico.yaml, cni-flannel.yaml, cni-none.yaml —
hence the ${CNI} above, read from lab.env like cluster-up.sh does. Omitting the patch
leaves Talos' default CNI in place, without the host-only VXLAN fix (see §9).
ℹ️ The VIP serves only kube-apiserver (:6443). For the Talos API
(-e/--endpoints, :50000) always target real node IPs (e.g. 192.168.56.10), never
the VIP — that is the Talos recommendation.
4.3 Apply the configuration (maintenance mode → --insecure)#
# Control plane(s) — single: only .10; HA: .10, .20, .30
talosctlapply-config--insecure-n192.168.56.10--file_out/controlplane.yaml
# Workers (.101, .102, … — independent of the number of CPs)
talosctlapply-config--insecure-n192.168.56.101--file_out/worker.yaml
talosctlapply-config--insecure-n192.168.56.102--file_out/worker.yaml
Each node installs itself on /dev/sda, then reboots from disk.
ℹ️ These commands leave the auto-generated hostname (talos-xxxxx). For deterministic
names, cluster-up.sh adds to every apply-config a --config-patch carrying a
HostnameConfig document (auto: "off" + hostname:).
⚠️ bootstrap runs only once, on a single control plane. In HA, the other CPs join
etcd automatically through discovery. If Talos answers "bootstrap is not available yet",
etcd is still finishing its pre-state: retry.
talosctlkubeconfig-n192.168.56.10./kubeconfig
exportKUBECONFIG="$PWD/kubeconfig"
talosctlhealth--wait-timeout10m-n192.168.56.10-e192.168.56.10
talosctl-n192.168.56.10getmembers# members seen by discovery
kubectlgetnodes-owide
After the bootstrap, the nodes stay NotReady until the CNI is installed — that is expected,
platform-up.sh handles it. See _k8s/README.md for the full dependency
chain and the list of addons.
⚠️ This layer requires CNI=cilium (the default). It relies on a LoadBalancer Service
that actually gets an IP, which only Cilium's L2 announcement (ARP) provides here. With
flannel, calico or none, the Gateway stays at EXTERNAL-IP <pending> and no UI is
reachable. Details in §9.
ℹ️ This whole subsection is for SELF_SIGNED=false only. With the default
(SELF_SIGNED=true), platform-up.sh signs the wildcard itself with openssl under a
local CA: no public DNS record and no Cloudflare token are needed, and the domain never
has to exist outside your machine. All you do is make the name resolve locally — an
/etc/hosts line pointing your subdomains at 192.168.56.200 — and, optionally, import
_out/self-signed/ca.crt to silence the browser warning. See
_k8s/self-signed/README.md. Read on only if you own a real
domain and want a publicly trusted certificate.
This is the part everyone forgets, and nothing works without it. Two things to do once,
outside the cluster.
a) A wildcard DNS record pointing at the Gateway IP.
Every lab UI is served through a single entry point — Envoy's LoadBalancer Service, which
takes the first IP of LB_POOL_START (192.168.56.200 by default). One record is
therefore enough for all the subdomains:
Type
Name
Content
Proxy
A
*.talos.lab.example.io
192.168.56.200
DNS only (🔘 grey cloud)
# the IP actually assigned (use this if you changed LB_POOL_START)
kubectl-nenvoy-gateway-systemgetsvc-owide|grepLoadBalancer
# check resolution
dig+shortargo.talos.lab.example.io# must answer 192.168.56.200
⚠️ The Cloudflare proxy (orange cloud) cannot work here. It would have to reach your
origin from the Internet, but 192.168.56.200 is a private, non-routable IP. In orange
you would get a 522 error. Stay on DNS-only: Envoy terminates TLS, not Cloudflare
— hence the need for a publicly trusted certificate (Let's Encrypt, see point b).
ℹ️ The lab is therefore only reachable from the host, or through access to the host-only
network (Tailscale — see
_k8s/README.md). A public wildcard
pointing at a private IP carries no exploitation risk, but it does publish the existence of
the lab and its IP plan: your call.
💡 With no DNS at all, you can test by short-circuiting resolution:
b) A Cloudflare API token for the DNS-01 challenge.
A wildcard cannot be validated over HTTP-01 (Let's Encrypt cannot reach a private IP), so
cert-manager uses DNS-01: it proves ownership by writing an _acme-challenge record, which
takes a token scoped to Zone/DNS/Edit + Zone/Zone/Read on your zone only — an All zones
token would let the lab rewrite the DNS of every domain you own. How to create it, and how the
certificate is then issued: _k8s/cert-manager/README.md.
Then in lab.env (gitignored — never in lab.env.example):
SELF_SIGNED=false# leave the default (true) and none of this is readLAB_DOMAIN=talos.lab.example.io# your domainLAB_DNS_ZONE=example.io# the Cloudflare zone (derived if empty)LAB_ACME_EMAIL=you@example.io# Let's Encrypt expiry noticesLAB_ACME_ISSUER=staging# staging (default) | prod — see the warning belowCLOUDFLARE_API_TOKEN=<your-token>
platform-up.sh creates the cloudflare-api-token Secret, substitutes the domain in the
manifests and waits for the certificate. Follow it with
kubectl -n envoy-gateway-system get certificate.
⚠️ prod costs a quota slot on every rebuild, and staging is the default on purpose.
The wildcard lives only in etcd, so vagrant destroy burns it and the next
platform-up.sh asks for a brand new one. Let's Encrypt production allows 5 certificates
per week for the same *.<LAB_DOMAIN>: the 6th rebuild fails with 429 rateLimited and the
lab stays without TLS until the 168 h window slides. Use prod on a stable lab, not while
iterating — and back the wildcard up before a destroy:
kubectl-nenvoy-gateway-systemgetsecretwildcard-<your-domain-in-dashes>-tls\-oyaml>_out/wildcard-tls.backup.yaml# contains the private key: _out/ is gitignored
vagrantstatus# VM status
vagranthalt# power off
vagrantup# power back on
vagrantdestroy-f# delete everything (dedicated disks included)
⚠️ After a destroy, also delete the local Talos state before starting over:
rm -rf _out kubeconfig.
⚠️ VirtualBox 7.x does not always clean up after a destroy, and the next up then fails
on VERR_ALREADY_EXISTS. Purge the leftovers with ./talos/virtualbox-cleanup.sh — details
and precautions in
TROUBLESHOOTING.md.
6.1 Adding workers (live, without breaking the cluster)#
To grow an already running cluster, start the new VMs and apply the existing worker
config to them (same secrets). Two rules:
Do not regenerate _out/ (nor FORCE=1): new secrets would break the cluster.
Do not re-run cluster-up.sh: it would wait for maintenance mode on already-installed
nodes and hang.
Example — going from 3 to 5 workers (talos-w4=.104, talos-w5=.105):
Raise WORKERS in lab.env (here WORKERS=5).
Start only the new VMs:
vagrantuptalos-w4talos-w5
Apply the existing worker config while pinning the hostname (Nth worker = talos-w<N>):
exportTALOSCONFIG="$PWD/_out/talosconfig"WK_IP_START=101;WK_IP_STEP=1# same values as lab.envfornin45;doip="192.168.56.$((WK_IP_START+(n-1)*WK_IP_STEP))"untiltalosctl-n"$ip"getdisks--insecure>/dev/null2>&1;dosleep5;donetalosctlapply-config--insecure-n"$ip"--file_out/worker.yaml\--config-patch"$(printf'apiVersion: v1alpha1\nkind: HostnameConfig\nauto: "off"\nhostname: talos-w%s\n'"$n")"done
The workers join automatically (their config already points at the VIP). Check:
kubectl get nodes -o wide → talos-w4/talos-w5 turn Ready.
ℹ️ Adding control planes follows the same logic (VM + apply-config of
controlplane.yaml, hostname talos-cp<N>); they join etcd through discovery, without
re-running bootstrap.
Symptoms and fixes have their own page, so this one stays about installing:
TROUBLESHOOTING.md — host and VirtualBox (VT-x/KVM conflict,
leftovers after a destroy), addressing and DHCP (stale leases, unreachable VIP), Talos nodes
(--insecure silence, KUBERNETES: n/a), cluster and pods (the NAT-NIC DNS trap, nodes staying
NotReady).
Addon-specific problems live in the ⚠️ pitfalls and 🚑 troubleshooting sections of each
_k8s/<addon>/README.md — index in _k8s/README.md.
No SSH → a dummy communicator (in the Vagrantfile) reports "ready" immediately so
that vagrant up does not hang.
No Talos box → we start from the empty pace/empty box and boot the metal-amd64.iso
ISO (SATA DVD drive, BIOS, boot disk then DVD).
Deterministic IPs → fixed MAC per VM + host-only DHCP reservations
(VBoxManage dhcpserver ... --fixed-address) created by a before :up trigger, stale leases
purged → the node takes its reserved IP on the 1st DHCP.
Deterministic hostnames → cluster-up.sh applies one HostnameConfig document per node
(auto: "off" + fixed hostname:) instead of the auto-generated name (talos-xxxxx). The
VirtualBox VMs carry the same name.
VIP / HA → talos/patch-cp.yaml sets a VIP shared between control planes (election
through etcd): the kube-apiserver endpoint stays stable even if a CP goes down.
Online discovery → talos/patch-all.yaml enables the discovery.talos.dev service and
disables the kubernetes registry, deprecated and incompatible with Kubernetes ≥ 1.32.
Default route through the NAT → deliberate (Internet access). What must be host-only is
the node's identity (kubelet nodeIP, etcd, VIP), not the default route.
CNI (in lab.env) expresses an intent, read in two places:
talos/cluster-up.sh applies the talos/cni-<CNI>.yaml patch, which fills in
cluster.network.cni in the control plane config. Talos installs flannel itself, at
bootstrap — without kubectl, by rendering an internal manifest.
_k8s/platform-up.sh installs the CNI in every other case, through Helm.
In practice: keep cilium. It is the only choice that makes the lab usable end to end.
calico is there to compare CNIs and to work on NetworkPolicy; flannel for a bare
cluster, if you just want to explore Talos.
The Cilium install (chart pinned to 1.19.6, L2 pool, --set devices=enp0s8) is documented
and scripted in _k8s/cilium/README.md — that is the source of
truth, platform-up.sh calls it for you.
⚠️ Calico does not announce LoadBalancer Service IPs. It can only do it over BGP,
which assumes a peer router — non-existent on a VirtualBox host-only network. With
CNI=calico you therefore have to install MetalLB (L2 mode) and adjust
_k8s/envoy-gateway/Envoy-Proxy.yml, which pins loadBalancerClass: io.cilium/l2-announcer (platform-up.sh strips that line outside Cilium). Full procedure:
_k8s/calico/README.md.
⚠️ Switching CNI on an existing cluster is not supported: vagrant destroy, then
rebuild. Two coexisting CNIs fight over the pod network.
⚠️ The key point on the Cilium side is the same as for flannel: pin the host-only
interface (enp0s8). Otherwise Cilium picks the default route NIC (the NAT) and the VTEPs
are broken.
ℹ️ The kubelet.nodeIP.validSubnets fix (talos/patch-all.yaml) still holds with Cilium:
the nodes' INTERNAL-IP, the source of the VTEPs, is already on 192.168.56.0/24.
Everything can be validated without touching a cluster:
makevalidate# script syntax + Vagrantfile + Talos config + doc links
makedocs# regenerates docs/index.html from every README (EN + FR)
makehelp# lists the targets
make validate-talos generates the config in a temporary directory, then feeds it to
talosctl validate --mode metal: no risk for _out/ nor for the cluster — unlike
FORCE=1 ./talos/cluster-up.sh, which regenerates the secrets and breaks a running cluster.
make validate-docs builds the docs into a throwaway directory and fails if a *.md link or
a cross-page anchor no longer resolves.
make validate-yaml parses every *.yaml / *.yml tracked by git.
On every pull request, the ci workflow re-runs three of these on a runner — shell syntax,
YAML, and the Vagrantfile — by calling the very same make targets, so a check cannot pass
in CI and fail on your machine. vagrant validate runs there with --ignore-provider, since a
runner has no VirtualBox.
This project is licensed under the Apache License 2.0 — see
LICENSE.
In short: use it, modify it, redistribute it, including commercially, as long as you keep the
copyright notice and state your changes. It comes with no warranty: this is a lab, do not
run it in production.
The license covers what this repo actually contains — the Vagrantfile, the talos/ and
_k8s/ scripts, the manifests and the documentation. It does not extend to the third-party
components those scripts download (Talos Linux, Cilium, Longhorn, Vault, Envoy Gateway,
chaoskube…), each of which keeps its own license.
Source: README.md21 sections
LISEZ-MOI.md
🏠 🐧Vagrant-Talos
Monte un cluster Talos Linux (Kubernetes immuable, piloté par API) sur VirtualBox
avec vagrant up + un script. Single control plane ou HA 3 CP avec VIP, puis une
couche applicative complète (Cilium, Envoy Gateway, Longhorn, Vault, PostgreSQL…).
Talos n'a ni SSH ni gestionnaire de paquets : l'OS est immuable et entièrement piloté
par l'API talosctl depuis l'hôte. Vagrant ne sert donc qu'à créer et démarrer les VMs ;
toute la configuration du cluster passe par talosctl.
Le parcours complet, en trois commandes :
vagrantup# crée les VMs, elles bootent en mode maintenance
./talos/cluster-up.sh# config + bootstrap etcd + kubeconfig + santé
./_k8s/platform-up.sh# couche applicative (suppose CNI=cilium, le défaut, cf. §9)
💡 Garde talosctl aligné sur TALOS_VERSION. C'est la version du binaire qui décide
du schéma de configuration généré ; un écart avec l'ISO produit des erreurs obscures.
Pour épingler talosctl sur une version précise au lieu de prendre la dernière :
ℹ️ L'ISO Talos (metal-amd64.iso) est téléchargée automatiquement au premier
vagrant up, dans iso/. Aucune box ni plugin Vagrant à installer : le « dummy
communicator » (pas de SSH) et la box vide pace/empty sont gérés par le Vagrantfile.
⚠️ VirtualBox et KVM ne peuvent pas partager VT-x. Si le module KVM est chargé,
vagrant up meurt sur VERR_VMX_IN_VMX_ROOT_MODE — le décharger d'abord :
DEPANNAGE.md.
🗺️ 2. Plan d'adressage (réseau host-only 192.168.56.0/24)#
Élément
IP
Hôte (host-only)
192.168.56.1
VIP API Kubernetes
192.168.56.5
talos-cp1 / cp2 / cp3
192.168.56.10 / .20 / .30
talos-w1 / w2 / w3 …
192.168.56.101 / .102 / .103 …
VIP LoadBalancer (Cilium L2)
192.168.56.200
Les IP sont déterministes : chaque VM a une MAC fixe et une réservation DHCP sur le
réseau host-only de VirtualBox, posée automatiquement par le Vagrantfile.
Chaque VM possède 2 cartes : NIC1 = NAT VirtualBox (Internet) et NIC2 = host-only192.168.56.x (réseau du cluster et de l'API).
ℹ️ Nommage des interfaces : depuis Talos 1.5, les cartes ont des noms prédictibles
(enp0s3, enp0s8…), pas eth0/eth1. La carte host-only s'appelle donc enp0s8
(NIC2 VirtualBox = bus PCI 0000:00:08.0). Les patches ne ciblent jamais par nom : la VIP
est posée via busPath et l'IP de node via le sous-réseau 192.168.56.0/24 → robuste quel
que soit le nommage.
⚠️ Le sous-réseau n'est configurable qu'à moitié.NETWORK pilote le Vagrantfile et
cluster-up.sh, mais 192.168.56.x est codé en dur dans talos/patch-all.yaml
(validSubnets), talos/patch-cp.yaml (vip.ip, advertisedSubnets) et
talos/cni-flannel.yaml (--iface-can-reach). Changer NETWORK sans éditer ces trois
fichiers produit un cluster silencieusement cassé.
La topologie vit dans lab.env, source unique lue par le Vagrantfileettalos/cluster-up.sh. Partir du modèle versionné (lab.env est gitignoré) :
cplab.env.examplelab.env
Variable
Défaut du modèle
Rôle
TALOS_VERSION
v1.13.7
ISO de boot et image d'installeur
INSTALLER_IMAGE
image Image Factory
installeur avec extensions (iscsi pour Longhorn)
CONTROL_PLANES
3
1 = single, 3 = HA avec VIP
WORKERS
3
nombre de workers
CP_MEM / CP_CPU
4096 / 2
ressources des control planes (jamais sous 3072 : etcd)
WK_MEM / WK_CPU
2048 / 2
ressources des workers
CNI
cilium
cilium, calico, flannel ou none (cf. §9)
LAB_DOMAIN
talos.lab.example.io
domaine des UI (*.<domaine> : wildcard TLS + HTTPRoute)
SELF_SIGNED
true
mode TLS : true = wildcard signé par une AC locale (openssl, sans domaine ni token), false = cert-manager + Let's Encrypt
LAB_DNS_ZONE
(vide → 2 derniers labels)
zone DNS du solveur ACME DNS-01
LAB_ACME_EMAIL
(vide → admin@<zone>)
compte Let's Encrypt (avis d'expiration) — SELF_SIGNED=false seulement
LAB_ACME_ISSUER
staging
émetteur ACME : staging (non trusté, quota énorme) ou prod (trusté, 5 certs/semaine) — SELF_SIGNED=false seulement
CLOUDFLARE_API_TOKEN
(vide)
DNS-01 de cert-manager (_k8s/) — SELF_SIGNED=false seulement
NETWORK
192.168.56
réseau host-only
CP_IP_START / CP_IP_STEP
10 / 10
→ .10, .20, .30
WK_IP_START / WK_IP_STEP
101 / 1
→ .101, .102, .103
LB_POOL_START / LB_POOL_END
192.168.56.200 / .230
plage des IP LoadBalancer ; la 1re est celle du Gateway, cible du DNS wildcard
Variables lues par cluster-up.sh mais absentes du modèle (toutes ont un défaut) :
VIP ($NETWORK.5), CLUSTER_NAME (talos-lab), INSTALL_DISK (/dev/sda),
OUT (_out), FORCE.
💡 Crée quand même lab.env. Sans lui, le Vagrantfile et cluster-up.sh retombent
sur leurs défauts internes — alignés tous les deux sur v1.13.7 et sur CNI=cilium, mais
tu perds l'image d'installeur Image Factory (extensions iscsi), donc Longhorn. Garde la
valeur de CNI dans lab.env cohérente avec ce que tu veux vraiment : cluster-up.sh
décide de ce que pose Talos, platform-up.sh de ce qu'installe Helm ensuite, et deux
valeurs divergentes donnent deux CNI concurrents — réseau pod cassé.
⚠️ Ne descends pas CP_MEM sous 3072. Des control planes à 2 Go affament etcd dès
qu'on empile les addons _k8s/, et le cluster s'effondre sous charge — observability/
exige explicitement 4 Go. C'est pour ça que le modèle livre 4096.
💰 Ce que coûte la topologie par défaut : 3 × 4 Go + 3 × 2 Go = 18 Go de RAM,
12 vCPU et ~6 × 20 Go de disque. Un hôte à 16 Go ne peut pas la faire tourner — utilise le
lab minimal ci-dessous.
💡 Lab minimal (2 VMs, ~6 Go). Suffisant pour Talos lui-même et la plateforme de base
(platform-up.sh) ; les addons de données supposent les 3 workers par défaut (Longhorn
réplique ×3, observability/ veut des CP à 4 Go). Éditer lab.env :
CONTROL_PLANES=1WORKERS=1
⚠️ Éditer le fichier, pas seulement exporter la variable.CONTROL_PLANES=1 vagrant up
n'agit que sur vagrant : cluster-up.sh relit lab.env et attendrait des control planes
.20/.30 jamais créés. Pour surcharger à la volée, passer la variable aux deux
commandes : CONTROL_PLANES=1 vagrant up && CONTROL_PLANES=1 ./talos/cluster-up.sh.
🌐 LAB_DOMAIN : le dépôt est public, donc son défaut est neutre
(talos.lab.example.io). Les manifestes _k8s/ portent ce domaine ; les scripts
*-up.sh le remplacent à la volée par LAB_DOMAIN (sed), sans jamais réécrire les
fichiers versionnés. Mets ton domaine dans lab.env (cf. _k8s/LISEZ-MOI.md).
Le 1er control plane est toujours talos-cp1 (192.168.56.10). Le nom de VM
VirtualBox/Vagrant est identique au hostname Talos (cf. §8).
vagrantup# les VMs bootent sur l'ISO, en mode maintenance
./talos/cluster-up.sh# tout le reste
cluster-up.sh enchaîne : génération de la config → application aux nodes (avec hostnames
déterministes) → bootstrap etcd → kubeconfig → attente de santé. Il affiche les export à
faire et un kubectl get nodes final.
Pour une autre topologie, éditer lab.env ou surcharger ponctuellement :
CONTROL_PLANES=1WORKERS=2./talos/cluster-up.sh# singleCNI=flannel./talos/cluster-up.sh# CNI posé par Talos, pas d'IP LB
⚠️ cluster-up.sh ne se relance pas sur un cluster déjà installé. Son attente du mode
maintenance interroge les nodes en --insecure ; un node déjà installé (mode sécurisé) ne
répond jamais. L'attente est bornée (WAIT_MAINTENANCE, 300 s) puis échoue avec un message
explicite — mais elle a quand même perdu cinq minutes sans rien appliquer. Pour agrandir un
cluster en route, voir §6.1.
⚠️ Ne régénère jamais _out/ (ni FORCE=1) sur un cluster en route : gen config
produit de nouveaux secrets et de nouvelles CA, ce qui casse le cluster existant. À faire
uniquement après un vagrant destroy.
🔍 Comprendre : les 6 étapes à la main (ce que le script automatise)
Utile pour apprendre, déboguer, ou reprendre à mi-chemin. La commande de génération est
strictement celle du script : --install-image comprise.
Produit _out/controlplane.yaml, _out/worker.yaml et _out/talosconfig. L'endpoint
kube-apiserver est la VIP192.168.56.5, en single comme en HA.
⚠️ --install-image n'est pas optionnel. Sans lui, tu installes l'installeur
classic, sans les extensions système — et Longhorn échoue plus tard sur
iscsiadm: not found. La valeur vient de INSTALLER_IMAGE (lab.env).
⚠️ Le patch CNI n'est pas optionnel non plus. Un fichier par intention —
cni-cilium.yaml (le défaut), cni-calico.yaml, cni-flannel.yaml, cni-none.yaml — d'où
le ${CNI} ci-dessus, lu dans lab.env comme le fait cluster-up.sh. Omettre ce patch
laisse le CNI par défaut de Talos, sans le correctif VXLAN host-only (cf. §9).
ℹ️ La VIP sert uniquement à kube-apiserver (:6443). Pour l'API Talos
(-e/--endpoints, :50000) on cible toujours des IP de nodes réelles (ex.
192.168.56.10), jamais la VIP — c'est la recommandation Talos.
4.3 Appliquer la configuration (mode maintenance → --insecure)#
# Control plane(s) — single : seulement .10 ; HA : .10, .20, .30
talosctlapply-config--insecure-n192.168.56.10--file_out/controlplane.yaml
# Workers (.101, .102, … — indépendant du nombre de CP)
talosctlapply-config--insecure-n192.168.56.101--file_out/worker.yaml
talosctlapply-config--insecure-n192.168.56.102--file_out/worker.yaml
Chaque node s'installe sur /dev/sda puis reboote sur le disque.
ℹ️ Ces commandes laissent le hostname auto-généré (talos-xxxxx). Pour les noms
déterministes, cluster-up.sh ajoute à chaque apply-config un --config-patch portant
un document HostnameConfig (auto: "off" + hostname:).
talosctlconfigendpoint192.168.56.10# HA : ajouter .20 .30
talosctlconfignode192.168.56.10
4.5 Bootstrap etcd (UNE SEULE FOIS, sur le 1er CP)#
talosctlbootstrap-n192.168.56.10
⚠️ bootstrap ne se lance qu'une seule fois, sur un seul control plane. En HA,
les autres CP rejoignent etcd automatiquement via la discovery. Si Talos répond
« bootstrap is not available yet », etcd finit son pre-state : réessayer.
talosctlkubeconfig-n192.168.56.10./kubeconfig
exportKUBECONFIG="$PWD/kubeconfig"
talosctlhealth--wait-timeout10m-n192.168.56.10-e192.168.56.10
talosctl-n192.168.56.10getmembers# membres vus par la discovery
kubectlgetnodes-owide
Le cluster nu ne fait rien d'utile. Tout le reste vit dans _k8s/ :
Cilium, Envoy Gateway, cert-manager, Longhorn, Vault, PostgreSQL, Prometheus/Loki, Kyverno,
Trivy, MinIO…
./talos/cluster-up.sh# 1. cluster (CNI=cilium par défaut : Talos ne pose rien)
./_k8s/platform-up.sh# 2. Cilium → Envoy Gateway → metrics-server → wildcard TLS
./_k8s/argocd/argocd-up.sh# 3. addons à la carte
Après le bootstrap, les nodes restent NotReady tant que le CNI n'est pas installé — c'est
normal, platform-up.sh s'en charge. Voir _k8s/LISEZ-MOI.md pour la chaîne
de dépendances complète et la liste des addons.
⚠️ Cette couche exige CNI=cilium (le défaut). Elle repose sur un Service
LoadBalancer qui obtient réellement une IP, ce que seule l'annonce L2 (ARP) de Cilium
fournit ici. Avec flannel, calico ou none, le Gateway reste en EXTERNAL-IP <pending> et aucune UI n'est joignable. Détail au §9.
ℹ️ Toute cette sous-section ne concerne que SELF_SIGNED=false. Avec le défaut
(SELF_SIGNED=true), platform-up.sh signe lui-même le wildcard avec openssl sous une AC
locale : ni enregistrement DNS public, ni token Cloudflare, et le domaine n'a jamais
besoin d'exister hors de ta machine. Il te reste seulement à faire résoudre le nom en local
— une ligne /etc/hosts pointant tes sous-domaines vers 192.168.56.200 — et, si tu veux,
à importer _out/self-signed/ca.crt pour faire taire l'avertissement du navigateur. Voir
_k8s/self-signed/LISEZ-MOI.md. Ne lis la suite que si tu
possèdes un vrai domaine et que tu veux un certificat publiquement trusté.
C'est la partie qu'on oublie, et rien ne fonctionne sans elle. Deux choses à faire une
seule fois, en dehors du cluster.
a) Un enregistrement DNS wildcard vers l'IP du Gateway.
Toutes les UI du lab sont servies par un seul point d'entrée — le Service LoadBalancer
d'Envoy, qui prend la première IP de LB_POOL_START (192.168.56.200 par défaut). Un
seul enregistrement suffit donc pour tous les sous-domaines :
Type
Nom
Contenu
Proxy
A
*.talos.lab.example.io
192.168.56.200
DNS only (nuage 🔘 gris)
# l'IP réellement attribuée (à utiliser si tu as changé LB_POOL_START)
kubectl-nenvoy-gateway-systemgetsvc-owide|grepLoadBalancer
# vérifier la résolution
dig+shortargo.talos.lab.example.io# doit répondre 192.168.56.200
⚠️ Le proxy Cloudflare (nuage orange) ne peut pas fonctionner ici. Il devrait joindre
ton origine depuis Internet, or 192.168.56.200 est une IP privée, non routable. En
orange tu obtiendrais une erreur 522. Reste en DNS-only : c'est Envoy qui termine
le TLS, pas Cloudflare — d'où la nécessité d'un certificat publiquement trusté
(Let's Encrypt, cf. point b).
ℹ️ Le lab n'est donc joignable que depuis l'hôte, ou via un accès au réseau host-only
(Tailscale — voir _k8s/LISEZ-MOI.md).
Un wildcard public qui pointe vers une IP privée est sans risque d'exploitation, mais il
publie l'existence du lab et son plan d'adressage : à toi de voir.
💡 Sans DNS du tout, tu peux tester en court-circuitant la résolution :
b) Un token API Cloudflare pour le challenge DNS-01.
Un wildcard ne peut pas être validé par HTTP-01 (Let's Encrypt n'atteint pas une IP privée) :
cert-manager passe donc par DNS-01, en prouvant la propriété du domaine via un enregistrement
_acme-challenge. Ça demande un token restreint à Zone/DNS/Edit + Zone/Zone/Read sur ta
seule zone — un token All zones laisserait le lab réécrire le DNS de tous tes domaines.
Comment le créer, et comment le certificat est ensuite émis :
_k8s/cert-manager/LISEZ-MOI.md.
Puis dans lab.env (gitignoré — jamais dans lab.env.example) :
SELF_SIGNED=false# en laissant le défaut (true), rien de tout ceci n'est luLAB_DOMAIN=talos.lab.example.io# ton domaineLAB_DNS_ZONE=example.io# la zone Cloudflare (déduite si vide)LAB_ACME_EMAIL=toi@example.io# avis d'expiration Let's EncryptLAB_ACME_ISSUER=staging# staging (défaut) | prod — voir l'avertissement plus basCLOUDFLARE_API_TOKEN=<ton-token>
platform-up.sh crée le Secret cloudflare-api-token, substitue le domaine dans les manifestes
et attend le certificat. Suivi avec kubectl -n envoy-gateway-system get certificate.
⚠️ prod coûte un slot de quota à chaque rebuild, et staging est le défaut exprès.
Le wildcard vit uniquement dans etcd : vagrant destroy le brûle, et le platform-up.sh
suivant en redemande un neuf. La production Let's Encrypt plafonne à 5 certificats par
semaine pour un même *.<LAB_DOMAIN> : le 6e rebuild échoue en 429 rateLimited et le lab
reste sans TLS jusqu'à ce que la fenêtre de 168 h glisse. Utilise prod sur un lab stable,
pas pendant que tu itères — et sauvegarde le wildcard avant un destroy :
kubectl-nenvoy-gateway-systemgetsecretwildcard-<ton-domaine-en-tirets>-tls\-oyaml>_out/wildcard-tls.backup.yaml# contient la clé privée : _out/ est gitignoré
vagrantstatus# état des VMs
vagranthalt# éteindre
vagrantup# rallumer
vagrantdestroy-f# tout supprimer (et les disques dédiés)
⚠️ Après un destroy, supprime aussi l'état Talos local avant de recommencer :
rm -rf _out kubeconfig.
⚠️ VirtualBox 7.x ne nettoie pas toujours après un destroy, et le up suivant échoue
alors sur VERR_ALREADY_EXISTS. Purger les résidus avec ./talos/virtualbox-cleanup.sh —
détails et précautions dans
DEPANNAGE.md.
6.1 Ajouter des workers (à chaud, sans casser le cluster)#
Pour agrandir un cluster déjà en route, on démarre les nouvelles VMs et on leur applique
la config worker existante (mêmes secrets). Deux règles :
Ne pas régénérer _out/ (ni FORCE=1) : de nouveaux secrets casseraient le cluster.
Ne pas relancer cluster-up.sh : il attendrait le mode maintenance sur des nodes déjà
installés et bloquerait.
Exemple — passer de 3 à 5 workers (talos-w4=.104, talos-w5=.105) :
Augmenter WORKERS dans lab.env (ici WORKERS=5).
Démarrer uniquement les nouvelles VMs :
vagrantuptalos-w4talos-w5
Appliquer la config worker existante en fixant le hostname (Nᵉ worker = talos-w<N>) :
exportTALOSCONFIG="$PWD/_out/talosconfig"WK_IP_START=101;WK_IP_STEP=1# mêmes valeurs que lab.envfornin45;doip="192.168.56.$((WK_IP_START+(n-1)*WK_IP_STEP))"untiltalosctl-n"$ip"getdisks--insecure>/dev/null2>&1;dosleep5;donetalosctlapply-config--insecure-n"$ip"--file_out/worker.yaml\--config-patch"$(printf'apiVersion: v1alpha1\nkind: HostnameConfig\nauto: "off"\nhostname: talos-w%s\n'"$n")"done
Les workers rejoignent automatiquement (leur config pointe déjà sur la VIP). Vérifier :
kubectl get nodes -o wide → talos-w4/talos-w5 passent Ready.
ℹ️ Ajouter des control planes suit la même logique (VM + apply-config de
controlplane.yaml, hostname talos-cp<N>) ; ils rejoignent etcd via la discovery,
sans relancer bootstrap.
Les symptômes et leurs correctifs ont leur propre page, pour que celle-ci reste consacrée à
l'installation : DEPANNAGE.md — hôte et VirtualBox (conflit VT-x/KVM,
résidus après un destroy), adressage et DHCP (baux périmés, VIP injoignable), nodes Talos
(silence en --insecure, KUBERNETES: n/a), cluster et pods (le piège DNS de la carte NAT,
nodes qui restent NotReady).
Les problèmes propres à un addon vivent dans les sections ⚠️ pièges et 🚑 dépannage de chaque
_k8s/<addon>/LISEZ-MOI.md — index dans _k8s/LISEZ-MOI.md.
Pas de SSH → un dummy communicator (dans le Vagrantfile) répond « prêt »
immédiatement pour que vagrant up ne reste pas bloqué.
Pas de box Talos → on part de la box vide pace/empty et on fait booter l'ISO
metal-amd64.iso (lecteur DVD SATA, BIOS, boot disque puis DVD).
IP déterministes → MAC fixe par VM + réservations DHCP host-only
(VBoxManage dhcpserver ... --fixed-address) posées par un trigger before :up, baux
périmés purgés → le node prend son IP réservée dès le 1er DHCP.
Hostnames déterministes → cluster-up.sh applique un document HostnameConfig par
node (auto: "off" + hostname: fixe) au lieu du nom auto-généré (talos-xxxxx). Les VMs
VirtualBox portent le même nom.
VIP / HA → talos/patch-cp.yaml pose une VIP partagée entre control planes (élection
via etcd) : l'endpoint kube-apiserver reste stable même si un CP tombe.
Discovery online → talos/patch-all.yaml active le service discovery.talos.dev et
désactive le registre kubernetes, déprécié et incompatible avec Kubernetes ≥ 1.32.
Route par défaut via le NAT → volontaire (accès Internet). Ce qui doit être host-only,
c'est l'identité du node (kubelet nodeIP, etcd, VIP), pas la route par défaut.
Deux façons d'installer un CNI, et une seule variable#
CNI (dans lab.env) exprime une intention, lue à deux endroits :
talos/cluster-up.sh applique le patch talos/cni-<CNI>.yaml, qui renseigne
cluster.network.cni de la config des control planes. C'est Talos qui installe
flannel, lui-même, au bootstrap — sans kubectl, en rendant un manifeste interne.
_k8s/platform-up.sh installe le CNI dans tous les autres cas, par Helm.
En pratique : garde cilium. C'est le seul choix qui rend le lab utilisable de bout en
bout. calico est là pour comparer les CNI et travailler les NetworkPolicy ;
flannel pour un cluster nu, si tu veux juste explorer Talos.
L'installation de Cilium (chart épinglé 1.19.6, pool L2, --set devices=enp0s8) est
documentée et scriptée dans _k8s/cilium/LISEZ-MOI.md — c'est la
source de vérité, platform-up.sh l'appelle pour toi.
⚠️ Calico n'annonce pas les IP de Service LoadBalancer. Il ne sait le faire qu'en
BGP, ce qui suppose un routeur pair — inexistant sur un réseau host-only VirtualBox.
Avec CNI=calico il faut donc installer MetalLB (mode L2) et adapter
_k8s/envoy-gateway/Envoy-Proxy.yml, qui épingle loadBalancerClass: io.cilium/l2-announcer (platform-up.sh retire cette ligne hors Cilium). Marche à suivre
complète : _k8s/calico/LISEZ-MOI.md.
⚠️ Changer de CNI sur un cluster existant n'est pas supporté : vagrant destroy puis
reconstruire. Deux CNI qui coexistent se disputent le réseau des pods.
⚠️ Le point clé côté Cilium est le même que pour flannel : épingler l'interface
host-only (enp0s8). Sinon Cilium prend la carte de la route par défaut (le NAT) et les
VTEP sont cassés.
ℹ️ Le correctif kubelet.nodeIP.validSubnets (talos/patch-all.yaml) reste valable avec
Cilium : l'INTERNAL-IP des nodes, source des VTEP, est déjà sur 192.168.56.0/24.
makevalidate# syntaxe des scripts + Vagrantfile + config Talos + liens de la doc
makedocs# régénère docs/index.html depuis tous les README (EN + FR)
makehelp# liste les cibles
make validate-talos génère la config dans un dossier temporaire puis la passe à
talosctl validate --mode metal : aucun risque pour _out/ ni pour le cluster — contrairement
à FORCE=1 ./talos/cluster-up.sh, qui régénère les secrets et casse un cluster en route.
make validate-docs construit la doc dans un dossier jetable et échoue si un lien *.md ou
une ancre inter-pages ne résout plus.
make validate-yaml parse tous les *.yaml / *.yml suivis par git.
À chaque pull request, le workflow ci rejoue trois de ces contrôles sur un runner —
syntaxe shell, YAML et Vagrantfile — en appelant les mêmes cibles make, pour qu'un test ne
puisse pas passer en CI et échouer sur ta machine. vagrant validate y tourne avec
--ignore-provider, un runner n'ayant pas VirtualBox.
Ce projet est sous licence Apache 2.0 — cf.
LICENSE.
En résumé : utilisation, modification et redistribution libres, y compris commerciales, à
condition de conserver la notice de copyright et d'indiquer les modifications apportées. Le tout
sans aucune garantie : c'est un lab, ne le passe pas en production.
La licence couvre ce que contient ce dépôt — le Vagrantfile, les scripts talos/ et _k8s/,
les manifestes et la documentation. Elle ne s'étend pas aux composants tiers que ces scripts
téléchargent (Talos Linux, Cilium, Longhorn, Vault, Envoy Gateway, chaoskube…), qui gardent
chacun leur propre licence.
Source : LISEZ-MOI.md21 sections
talos/UPGRADE.md
⬆️Upgrade Talos (and Kubernetes)
Procedure validated for real on this lab (v1.13.5 → v1.13.7): 8 nodes, ~10 min,
zero Kubernetes API downtime. Measurements in §7.
Reference at test time: Talos v1.13.7, Kubernetes v1.36.2, CNI=cilium,
3 CP + 5 workers. Adapt the IPs to your topology (lab.env); the repo ships 3 CP + 3 workers.
⚠️ talosctl must be ≥ the target version. Check before you start:
talosctl version --client.
A/B scheme: the new image is written to the inactive partition, the node reboots onto
it. If the boot fails, Talos rolls back automatically. Manual rollback:
talosctl -n <ip> rollback.
The upgrade preserves etcd and the machine config. The EPHEMERAL partition (/var) is
kept except without --preserve on a single-node. In HA you can wipe a node (etcd
rebuilds from the quorum), but for a node that stores data (Longhorn →
/var/lib/longhorn): always --preserve.
⚠️ INSTALLER_IMAGE masks TALOS_VERSION.lab.env.example sets an Image Factory
image whose tag carries its own version
(factory.talos.dev/installer/<schematic>:v1.13.7). Bumping TALOS_VERSION alone then
changes the ISO only: the disk would stay on the old version. Both lines must be updated
together.
💡 The fallback defaults in the Vagrantfile and in cluster-up.sh are aligned
(v1.13.7): on every bump, update both at the same time as lab.env, otherwise a lab
brought up without lab.env would start again on the old version.
For an upgrade the ISO is irrelevant: you change the installer image of the already
installed nodes (§3), then update lab.env for future rebuilds.
Pre-flight — never start from an already degraded cluster:
exportTALOSCONFIG=_out/talosconfigKUBECONFIG=./kubeconfig
talosctl-n192.168.56.10-e192.168.56.10health# healthy cluster
talosctl-n192.168.56.10-e192.168.56.10etcdstatus# 3 healthy members
Order: one node at a time, workers first, then the control planes.
⚠️ NEVER upgrade two CPs in parallel: the etcd quorum is 2/3, losing two of them breaks
the cluster. The .5 VIP switches over to another CP on its own during the reboot.
NEW=v1.14.x# target versionIMG=ghcr.io/siderolabs/installer:${NEW}# ⚠️ see the "extensions" callout below# a) Workers, one by oneforipin101102103104105;dotalosctl-n192.168.56.$ip-e192.168.56.10upgrade--image"$IMG"--preserve--wait
kubectlwait--for=condition=Readynode/talos-w$((ip-100))--timeout=5m
done# b) Control planes, one by one, etcd checked BETWEEN each oneforipin102030;dotalosctl-n192.168.56.$ip-e192.168.56.20upgrade--image"$IMG"--preserve--wait
talosctl-n192.168.56.10-e192.168.56.10etcdstatus
done
Option
When
--preserve
always here (keeps /var, hence the Longhorn data)
--wait
blocks until the node comes back healthy
--stage
if a node refuses to upgrade live (mount locks) → applied at the next reboot
--drain=false
if the drain stays stuck (see §7, Longhorn PDB)
⚠️ -e/--endpoints must never point at the target node. For a CP, aim at another CP
(otherwise you lose access when it reboots); a worker serves no kubeconfig at all. Details
in §7.
⚠️ On 2-3 GB VMs, let the disk/etcd load settle between two nodes: I/O starvation breaks the
quorum.
This orchestrates the static pods' apiserver / controller-manager / scheduler / kubelet, one
component at a time. Check the supported Talos ↔ Kubernetes skew in the Talos release notes
first.
Adding iscsi-tools / util-linux-tools (required by Longhorn) is not a kubectl job:
it is an upgrade to an Image Factory installer image that bakes them in.
⚠️ NEVER upgrade a "factory" node to the classic ghcr.io installer: that strips the
extensions and breaks Longhorn. The factory image tag must carry the target version,
with the same schematic ID.
💡 For a fresh cluster it is simpler to add
--config-patch @_k8s/longhorn/patch-longhorn.yaml to the gen config — see
../_k8s/longhorn/README.md.
Bump TALOS_VERSIONandINSTALLER_IMAGE in lab.env (and in the lab.env.example
template) → future vagrant up / cluster-up.sh runs will start on the right version, ISO
and installer.
Run on 3 CP (3 GB / 3 vCPU) + 5 workers (2 GB / 2 vCPU), with Longhorn and Argo CD deployed,
rolling one node at a time, with a probe hitting https://192.168.56.5:6443/livez every ~1 s.
Endpoint = the target node → failure. The drain done by talosctl upgrade fetches the
kubeconfig through the endpoint; a worker serves none
(Unimplemented: kubeconfig is only available on control plane nodes) → the upgrade errors
out before the reboot even starts.
Fix: point --endpoints at a control plane — and to upgrade a CP, at a CP other
than the target. The talosconfig endpoints (the 3 CPs) are fine for the workers.
Drain stuck on Longhorn. The instance-manager PodDisruptionBudget blocks eviction:
the drain runs until --drain-timeout (5 min).
Lab fix: --drain=false (straight reboot; --preserve keeps /var/lib/longhorn, and
Longhorn rebuilds the replicas when the node returns). In production: tune Longhorn's node
drain policy.
API downtime: none. 1056 probes over ~17 min covering the 8 reboots (including the 3 CPs)
→ 100 % answered, 0 DOWN, longest outage = 0 s. The VIP switching between control planes is
transparent at one-second granularity. etcd stayed at 3/3, Kubernetes unchanged (v1.36.2).
Extensions preserved: iscsi-tools + util-linux-tools still present after the upgrade,
thanks to the factory image tagged :v1.13.7 (same schematic ID as :v1.13.5).
Post-upgrade: Longhorn 5 nodes Ready, Argo CD and the Longhorn UI served over trusted HTTPS
(200).
Procédure validée en réel sur ce lab (v1.13.5 → v1.13.7) : 8 nodes, ~10 min,
zéro interruption de l'API Kubernetes. Les mesures sont au §7.
Référence au moment du test : Talos v1.13.7, Kubernetes v1.36.2, CNI=cilium,
3 CP + 5 workers. Adapte les IP à ta topologie (lab.env) ; le dépôt livre 3 CP + 3 workers.
⚠️ talosctl doit être ≥ la version cible. Vérifie avant de commencer :
talosctl version --client.
Schéma A/B : la nouvelle image est écrite sur la partition inactive, le node reboote
dessus. Si le démarrage échoue, Talos rollback automatiquement. Rollback manuel :
talosctl -n <ip> rollback.
L'upgrade préserve etcd et la config machine. La partition EPHEMERAL (/var) est
conservée sauf sans --preserve sur un single-node. En HA on peut wiper un node (etcd
se reconstruit depuis le quorum), mais pour un node qui stocke des données (Longhorn →
/var/lib/longhorn) : toujours --preserve.
schéma de config généré, compatibilité des commandes
ton installation locale
⚠️ INSTALLER_IMAGE masque TALOS_VERSION.lab.env.example définit une image
Image Factory dont le tag porte sa propre version
(factory.talos.dev/installer/<schematic>:v1.13.7). Bumper TALOS_VERSION seul ne change
alors que l'ISO : le disque resterait sur l'ancienne version. Les deux lignes doivent
être mises à jour ensemble.
💡 Les défauts de repli du Vagrantfile et de cluster-up.sh sont alignés (v1.13.7) :
à chaque bump, mets les deux à jour en même temps que lab.env, sinon un lab monté
sans lab.env repartirait sur l'ancienne version.
Pour un upgrade, l'ISO ne sert pas : on change l'image d'installeur des nodes déjà
installés (§3), puis on met lab.env à jour pour les futurs rebuilds.
Pré-vol — ne jamais partir d'un cluster déjà dégradé :
exportTALOSCONFIG=_out/talosconfigKUBECONFIG=./kubeconfig
talosctl-n192.168.56.10-e192.168.56.10health# cluster sain
talosctl-n192.168.56.10-e192.168.56.10etcdstatus# 3 membres sains
Ordre : un node à la fois, workers d'abord, puis control planes.
⚠️ Ne JAMAIS upgrader deux CP en parallèle : le quorum etcd est 2/3, en perdre deux
casse le cluster. Le VIP .5 bascule seul vers un autre CP pendant le reboot.
NEW=v1.14.x# version cibleIMG=ghcr.io/siderolabs/installer:${NEW}# ⚠️ voir l'encart « extensions » ci-dessous# a) Workers, un par unforipin101102103104105;dotalosctl-n192.168.56.$ip-e192.168.56.10upgrade--image"$IMG"--preserve--wait
kubectlwait--for=condition=Readynode/talos-w$((ip-100))--timeout=5m
done# b) Control planes, un par un, etcd vérifié ENTRE chaqueforipin102030;dotalosctl-n192.168.56.$ip-e192.168.56.20upgrade--image"$IMG"--preserve--wait
talosctl-n192.168.56.10-e192.168.56.10etcdstatus
done
Option
Quand
--preserve
toujours ici (garde /var, donc les données Longhorn)
--wait
bloque jusqu'au retour du node en bonne santé
--stage
si un node refuse l'upgrade à chaud (verrous montés) → appliqué au prochain reboot
--drain=false
si le drain reste bloqué (cf. §7, PDB Longhorn)
⚠️ -e/--endpoints ne doit jamais désigner le node cible. Pour un CP, viser un autre
CP (sinon on perd l'accès quand il reboote) ; un worker ne sert pas de kubeconfig du tout.
Détail au §7.
⚠️ Sur des VM à 2-3 Go, laisser retomber la charge disque/etcd entre deux nodes : la famine
I/O casse le quorum.
Orchestre apiserver / controller-manager / scheduler / kubelet des static pods, un composant
à la fois. Vérifier le skew Talos ↔ Kubernetes supporté dans les release notes Talos avant.
Ajouter iscsi-tools / util-linux-tools (requis par Longhorn) n'est pas un kubectl :
c'est un upgrade vers une image d'installeur Image Factory qui les bake.
⚠️ Ne JAMAIS upgrader un node « factory » vers l'installeur ghcr.io classic : cela
retire les extensions et casse Longhorn. Le tag de l'image factory doit porter la
version cible, avec le même schematic ID.
💡 Pour un cluster neuf, il est plus simple d'ajouter
--config-patch @_k8s/longhorn/patch-longhorn.yaml au gen config — voir
../_k8s/longhorn/LISEZ-MOI.md.
Bumper TALOS_VERSIONetINSTALLER_IMAGE dans lab.env (et le modèle
lab.env.example) → les futurs vagrant up / cluster-up.sh repartiront sur la bonne
version, ISO et installeur.
Bumper le binaire talosctl local pour rester aligné.
Déroulé sur 3 CP (3 Go / 3 vCPU) + 5 workers (2 Go / 2 vCPU), Longhorn et Argo CD déployés,
en rolling un node à la fois, avec une sonde interrogeant https://192.168.56.5:6443/livez
toutes les ~1 s.
Endpoint = le node cible → échec. Le drain de talosctl upgrade récupère le
kubeconfig via l'endpoint ; un worker n'en sert pas
(Unimplemented: kubeconfig is only available on control plane nodes) → l'upgrade sort en
erreur avant même le reboot.
Parade : --endpoints pointe un control plane — et pour upgrader un CP, un CP
autre que la cible. Les endpoints de la talosconfig (les 3 CP) conviennent pour les
workers.
Drain bloqué par Longhorn. Le PodDisruptionBudget des instance-manager empêche
l'éviction : le drain tourne jusqu'au --drain-timeout (5 min).
Parade lab : --drain=false (reboot direct ; --preserve garde /var/lib/longhorn,
Longhorn reconstruit les réplicas au retour). En production : régler la node drain policy
de Longhorn.
Interruption API : aucune. 1056 sondes sur ~17 min couvrant les 8 reboots (dont les
3 CP) → 100 % de réponses, 0 DOWN, plus longue coupure = 0 s. La bascule du VIP entre control
planes est transparente à la granularité d'une seconde. etcd est resté à 3/3, Kubernetes
inchangé (v1.36.2).
Extensions préservées : iscsi-tools + util-linux-tools toujours présents après
upgrade, grâce à l'image factory en :v1.13.7 (même schematic ID que :v1.13.5).
Post-upgrade : Longhorn 5 nodes Ready, Argo CD et UI Longhorn servis en HTTPS trusté (200).
Symptoms and fixes for the lab, from the host up to the pods. Back to the install path:
README.md · application layer: _k8s/README.md ·
upgrades: talos/UPGRADE.md.
Each _k8s/<addon>/README.md carries its own ⚠️ pitfalls and 🚑 troubleshooting section for
what is specific to it (Longhorn, Vault, Calico…). This page covers the lab itself: the host,
VirtualBox, addressing and the Talos nodes.
VT-x conflict: unload KVM before starting VirtualBox#
VirtualBox and KVM cannot use VT-x at the same time. If the KVM kernel module is loaded,
vagrant up fails at boot:
VBoxManage: error: VT-x is being used by another hypervisor (VERR_VMX_IN_VMX_ROOT_MODE).
VBoxManage: error: VirtualBox can't operate in VMX root mode.
Check, then unload KVM (needs a real terminal: sudo asks for a password):
# 1. Is KVM loaded? (Intel: kvm_intel; AMD: kvm_amd)
lsmod|grepkvm
# 2. Unload (fails if a KVM/libvirt VM is still running — stop it first)
sudomodprobe-rkvm_intelkvm# AMD: sudo modprobe -r kvm_amd kvm
💡 Persistence. KVM is reloaded on every boot. If this host is never used for
KVM/libvirt, blacklist it once and for all:
vagrant up fails after a destroy (VirtualBox leftovers)#
VirtualBox 7.x (linked clones) does not always clean up after a destroy. Symptom on the next
up:
The name of your virtual machine couldn't be set because VirtualBox
is reporting another VM with that name already exists.
VBoxManage: error: Could not rename the directory '.../temp_clone_...'
to '.../talos-cp1' ... (VERR_ALREADY_EXISTS)
Two layers of leftovers pile up: orphan directories~/VirtualBox VMs/talos-*/ and dead
entries in the media registry (talos-* disks still registered + accumulated
inaccessible entries), which would then make the up fail on "medium already registered".
DRY_RUN=1./talos/virtualbox-cleanup.sh# shows what would be deleted
./talos/virtualbox-cleanup.sh# actually purges
⚠️ Run it AFTERvagrant destroy, never on a running cluster. The script targets the
talos- prefix (PREFIX= variable) and the temp_clone_* VMs: if another Vagrant
project is in the middle of a up on the same machine, its temporary clone would be deleted.
💡 A destroy that reports success can still leave those directories behind, each holding
a small snapshot .vmdk. So run the cleanup after every destroy, not just after a visibly
failed one. In dry-run the directories show up as "kept (contains files)": that is normal, the
real run deletes the .vmdk first and then finds them empty.
The pace/empty box exposes its disk on a controller named SAS (replaced with SATA/AHCI). If
a future version of the box changes that name, list it with
VBoxManage showvminfo <vm> | grep -i "Storage Controller Name" and adjust the Vagrantfile.
⚠️ The Vagrantfile uses the existence of the disk as a provisioning sentinel. If a
destroy fails and leaves .vagrant/talos-disks/<vm>.vdi behind, the next up creates a VM
with no disk attached and the install dies with an obscure error. Clean up with
./talos/virtualbox-cleanup.sh.
Talos retries DHCP in a loop: wait ~30 s. Otherwise vagrant reload <node> (the trigger
re-arms the host-only DHCP with the reservations). To see a VM's real IP, open its console
(vb.gui = false → true in the Vagrantfile): Talos prints its IP on screen.
A node takes an unexpected IP (stale DHCP leases)#
Symptom: talosctl -n <reserved-ip> ... --insecure returns no route to host while
another IP answers. Cause: VirtualBox honours an already-acked DHCP lease before
applying the MAC→IP reservations. An old lease (typically in the ~.100 range, inherited from
vboxnet0's default DHCP server) overrides the reservation.
The before :up trigger creates the MAC→IP reservations and purges those leases
before the VMs boot (dhcpd restarted empty), so that every node gets its reserved IP on
its 1st DHCP DISCOVER. The after :destroy trigger purges them too.
To fix an already started cluster without destroying everything:
# 1. power off the nodes (maintenance mode => no data lost)forvintalos-cp1talos-cp2talos-cp3;doVBoxManagecontrolvm"$v"poweroff;done# 2. purge the host-only network lease file (adjust vboxnet0 if needed)CFG="${VBOX_USER_HOME:-$HOME/.config/VirtualBox}"
rm-f"$CFG"/HostInterfaceNetworking-vboxnet0-Dhcpd.leases*
VBoxManagedhcpserverrestart--networkHostInterfaceNetworking-vboxnet0
# 3. power back on: the nodes redo a DHCP DISCOVER and get their reserved IP
vagrantup
Check: talosctl -n 192.168.56.10 version --insecure must answer NODE: 192.168.56.10.
The VIP only appears after etcd's bootstrap. Check that the host-only NIC really is
0000:00:08.0: talosctl -n 192.168.56.10 get links, then get addresses. If the interface
differs, adjust busPath in talos/patch-cp.yaml.
The node is not in maintenance mode yet, or has no host-only IP. Check
talosctl -n <ip> get disks --insecure and §2.
⚠️ An already installed node (secure mode) never answers --insecure: that is expected,
not a fault. This is exactly why cluster-up.sh must not be re-run on a live cluster — to
grow one, see §6.1 of the README.
Normal beforeapply-config. The dashboard derives that version from the kubelet image tag
in the KubeletSpec resource, which only exists once the machine config has been applied. In
maintenance mode no kubelet is configured → n/a. Nothing to fix: look at the console
after applying the config. Check outside the console:
talosctl -n <ip> get kubeletspec or kubectl get nodes.
Symptom: ping 1.1.1.1 works from a pod, but nslookup/apk update fail
(DNS: transient error).
Cause: flannel picks the public IP of its VXLAN tunnel on the default route
interface = the NAT NIC (10.0.2.15, identical on every VM). All the VTEPs then point
at an isolated NAT → cross-node pod traffic is broken. DNS fails because CoreDNS often
runs on a different node than the client pod; Internet egress, on the other hand, leaves
through the local NAT and works.
kubectlgetnodes-ocustom-columns='NODE:.metadata.name,FLANNEL-IP:.metadata.annotations.flannel\.alpha\.coreos\.com/public-ip'# KO if FLANNEL-IP = 10.0.2.15 everywhere; OK if = 192.168.56.10/.20/.30
The fix already lives in talos/cni-flannel.yaml (--iface-can-reach=192.168.56.1). On a
rebuild it is picked up at bootstrap time. On an already started cluster, Talos does
not re-push the manifest update on its own → patch the DaemonSet:
ℹ️ Same root cause, same countermeasure for the other CNIs: Cilium pins devices=enp0s8, and
Calico pins nodeAddressAutodetectionV4.cidrs. The NAT NIC being identical on every VM is
the recurring trap of this lab — see README.md.
Expected with CNI=cilium, calico or none: Talos installs no CNI, and a node without a pod
network never reports Ready. ./_k8s/platform-up.sh installs it and unblocks them. Only
flannel is laid down by Talos itself, at bootstrap time.
If they are stillNotReady after the CNI install, look at the CNI pods first
(kubectl -n kube-system get pods for Cilium, kubectl -n calico-system get pods for Calico),
then the addon's own README.
Chaque _k8s/<addon>/LISEZ-MOI.md porte ses propres sections ⚠️ pièges et 🚑 dépannage pour
ce qui lui est spécifique (Longhorn, Vault, Calico…). Cette page couvre le lab lui-même :
l'hôte, VirtualBox, l'adressage et les nodes Talos.
vagrant up échoue après un destroy (résidus VirtualBox)#
VirtualBox 7.x (clones liés) ne nettoie pas toujours après un destroy. Symptôme au up
suivant :
The name of your virtual machine couldn't be set because VirtualBox
is reporting another VM with that name already exists.
VBoxManage: error: Could not rename the directory '.../temp_clone_...'
to '.../talos-cp1' ... (VERR_ALREADY_EXISTS)
Deux couches de résidus s'accumulent : les dossiers orphelins~/VirtualBox VMs/talos-*/ et
des entrées mortes dans le registre de médias (disques talos-* encore enregistrés +
entrées inaccessible accumulées), qui feraient ensuite échouer le up sur « medium already
registered ».
DRY_RUN=1./talos/virtualbox-cleanup.sh# montre ce qui serait supprimé
./talos/virtualbox-cleanup.sh# purge réellement
⚠️ À lancer APRÈSvagrant destroy, jamais sur un cluster en route. Le script cible le
préfixe talos- (variable PREFIX=) et les VMs temp_clone_* : si un autre projet
Vagrant est en cours de up sur la même machine, son clone temporaire serait supprimé.
💡 Un destroy qui rapporte un succès peut tout de même laisser ces dossiers derrière lui,
chacun contenant un petit snapshot .vmdk. Lance donc la purge après chaquedestroy, pas
seulement après un échec visible. En dry-run les dossiers apparaissent « CONSERVÉ (contient des
fichiers) » : c'est normal, le vrai passage supprime d'abord le .vmdk puis les trouve vides.
vagrant up échoue sur storagectl ... --remove SAS#
La box pace/empty expose son disque sur un contrôleur nommé SAS (remplacé par SATA/AHCI). Si
une future version de la box change ce nom, le lister avec
VBoxManage showvminfo <vm> | grep -i "Storage Controller Name" et ajuster le Vagrantfile.
⚠️ Le Vagrantfile utilise l'existence du disque comme sentinelle de provisioning. Si un
destroy échoue et laisse .vagrant/talos-disks/<vm>.vdi derrière lui, le up suivant crée
une VM sans disque attaché et l'installation meurt sur une erreur obscure. Nettoyer avec
./talos/virtualbox-cleanup.sh.
Talos réessaie le DHCP en boucle : attendre ~30 s. Sinon vagrant reload <node> (le trigger
réarme le DHCP host-only avec les réservations). Pour voir l'IP réelle d'une VM, ouvrir sa
console (vb.gui = false → true dans le Vagrantfile) : Talos affiche son IP à l'écran.
Un node prend une IP inattendue (baux DHCP périmés)#
Symptôme : talosctl -n <ip-réservée> ... --insecure renvoie no route to host alors qu'une
autre IP répond. Cause : VirtualBox honore un bail DHCP déjà ackedavant d'appliquer
les réservations MAC→IP. Un vieux bail (typiquement dans la plage ~.100, héritée du serveur
DHCP par défaut de vboxnet0) prend le pas sur la réservation.
Le trigger before :up crée les réservations MAC→IP et purge ces baux avant le
démarrage des VMs (dhcpd redémarré à vide), pour que chaque node obtienne son IP réservée dès
son 1er DHCP DISCOVER. Le trigger after :destroy les purge aussi.
Pour réparer un cluster déjà démarré sans tout détruire :
# 1. éteindre les nodes (mode maintenance => aucune donnée perdue)forvintalos-cp1talos-cp2talos-cp3;doVBoxManagecontrolvm"$v"poweroff;done# 2. purger le fichier de baux du réseau host-only (adapter vboxnet0 si besoin)CFG="${VBOX_USER_HOME:-$HOME/.config/VirtualBox}"
rm-f"$CFG"/HostInterfaceNetworking-vboxnet0-Dhcpd.leases*
VBoxManagedhcpserverrestart--networkHostInterfaceNetworking-vboxnet0
# 3. rallumer : les nodes refont un DHCP DISCOVER et obtiennent leur IP réservée
vagrantup
Vérification : talosctl -n 192.168.56.10 version --insecure doit répondre
NODE: 192.168.56.10.
La VIP n'apparaît qu'après le bootstrap d'etcd. Vérifier que la carte host-only est bien
0000:00:08.0 : talosctl -n 192.168.56.10 get links, puis get addresses. Si l'interface
diffère, ajuster busPath dans talos/patch-cp.yaml.
Le node n'est pas encore en mode maintenance, ou n'a pas d'IP host-only. Vérifier
talosctl -n <ip> get disks --insecure et le §2.
⚠️ Un node déjà installé (mode sécurisé) ne répond jamais en --insecure : c'est attendu,
pas une panne. C'est exactement pour ça qu'il ne faut pas relancer cluster-up.sh sur un
cluster en route — pour l'agrandir, voir
§6.1 du LISEZ-MOI.
Normal avant l'apply-config. Le dashboard déduit cette version du tag de l'image kubelet
dans la ressource KubeletSpec, qui n'existe qu'une fois la configuration machine appliquée. En
mode maintenance aucun kubelet n'est configuré → n/a. Rien à corriger : regarder la console
après l'application de la config. Vérifier hors console :
talosctl -n <ip> get kubeletspec ou kubectl get nodes.
Symptôme : ping 1.1.1.1 fonctionne depuis un pod, mais nslookup/apk update échouent
(DNS: transient error).
Cause : flannel choisit l'IP publique de son tunnel VXLAN sur l'interface de la route par
défaut = la carte NAT (10.0.2.15, identique sur toutes les VMs). Tous les VTEP
pointent alors vers un NAT isolé → le trafic pod cross-node est cassé. Le DNS échoue parce
que CoreDNS tourne souvent sur un autre node que le pod client ; la sortie Internet, elle,
part par le NAT local et fonctionne.
kubectlgetnodes-ocustom-columns='NODE:.metadata.name,FLANNEL-IP:.metadata.annotations.flannel\.alpha\.coreos\.com/public-ip'# KO si FLANNEL-IP = 10.0.2.15 partout ; OK si = 192.168.56.10/.20/.30
Le correctif vit déjà dans talos/cni-flannel.yaml
(--iface-can-reach=192.168.56.1). Sur un rebuild il est pris en compte au bootstrap. Sur un
cluster déjà démarré, Talos ne repousse pas la mise à jour du manifeste de lui-même →
patcher le DaemonSet :
ℹ️ Même cause racine, même parade pour les autres CNI : Cilium épingle devices=enp0s8, et
Calico épingle nodeAddressAutodetectionV4.cidrs. La carte NAT identique sur toutes les VMs
est LE piège récurrent de ce lab — cf. LISEZ-MOI.md.
Attendu avec CNI=cilium, calico ou none : Talos n'installe aucun CNI, et un node sans
réseau pod ne passe jamais Ready. ./_k8s/platform-up.sh l'installe et les débloque. Seul
flannel est posé par Talos lui-même, au bootstrap.
S'ils sont toujoursNotReady après l'installation du CNI, regarder d'abord les pods du CNI
(kubectl -n kube-system get pods pour Cilium, kubectl -n calico-system get pods pour
Calico), puis le README de l'addon.
Source : DEPANNAGE.md16 sections
_k8s/README.md
☸️_k8s/ — the lab's application layer
Everything that gets installed after the cluster bootstrap, with kubectl/helm from
the host — including the CNI, unless you picked CNI=flannel (the only one Talos
installs itself, at bootstrap time).
Read this directory in two passes: a base platform (4 components, a single script), then
independent addons you add opt-in, each in its own directory with its own *-up.sh.
# 1. Bootstrap with CNI=cilium (the repo default): Talos installs no CNI, this layer doesCNI=cilium./talos/cluster-up.sh
# 2. The base platform: Cilium → Envoy Gateway → metrics-server → wildcard TLS
./_k8s/platform-up.sh
# 3. The addons, opt-in
./_k8s/longhorn/longhorn-up.sh# block storage (StorageClass longhorn)
./_k8s/vault-cluster/vault-up.sh# Vault HA — needs longhorn-up.sh first
./_k8s/argocd/argocd-up.sh
./_k8s/chaos-kube/chaoskube-up.sh# optional: chaos, deletes 1 pod/hour
⚠️ CNI=cilium is the only "everything on" choice. This layer needs a LoadBalancer
Service that really gets an IP: that is exactly what Cilium's L2 announcement (ARP) does.
With flannel, calico or none, the Gateway stays at EXTERNAL-IP <pending> and no UI
is reachable.
CNI (in lab.env) is read in two places: talos/cluster-up.sh applies the
talos/cni-<CNI>.yaml patch at bootstrap time, then platform-up.sh installs the CNI when
Talos did not.
Every link assumes the previous one: no LoadBalancer IP without an L2 announcer, no HTTPS
without the Gateway, no UI without a certificate on the :443 listener.
cluster Ready (Talos has bootstrapped, CNI per lab.env)
│
├─ 1. CNI cilium/ (default, + L2 pool → LoadBalancer IP .200)
│ or calico/ (CNI only) or flannel (already there) or nothing
├─ 2. envoy-gateway/ Envoy controller + main-gateway (listeners :80 and :443)
├─ 3. metric-server metrics.k8s.io API (kubectl top, HPA)
└─ 4. wildcard TLS *.talos.lab.example.io — one of two modes, per SELF_SIGNED
│ true (default) → self-signed/ openssl, local CA, no cert-manager
│ false → cert-manager/ Let's Encrypt DNS-01 Cloudflare
│
└─ addons: storage → databases → secrets → observability → security
That is exactly the order of platform-up.sh ([1/4] → [4/4]): metrics-server before
the certificate. Both TLS modes fill the same Secret
(wildcard-<LAB_DOMAIN with dashes>-tls), so no addon ever has to know which one you picked
— they all just attach an HTTPRoute to the https listener.
openssl on the host — only if SELF_SIGNED=true (the default)
generates the wildcard TLS
openssl version
CLOUDFLARE_API_TOKEN in lab.env — only if SELF_SIGNED=false
DNS-01 for the TLS wildcard
read by platform-up.sh
LAB_DOMAIN in lab.env
UI domain (TLS wildcard + HTTPRoute)
sed -n 's/^LAB_DOMAIN=//p' lab.env
StorageClass, depending on the addon
local-path, longhorn or longhorn-r1
kubectl get sc
💡 The StorageClasses are not interchangeable: longhorn-r1 (1 replica) is the base for
apps that already replicate at the application level (PostgreSQL, Vault), longhorn
(3 replicas) for everything else, local-path for anything ephemeral. See
longhorn/ and local-path-storage/.
The repo is public: every versioned manifest carries a neutral domain,
talos.lab.example.io. Put yours in lab.env (gitignored):
echo'LAB_DOMAIN=talos.lab.my-domain.tld'>>lab.env
The *-up.sh scripts (platform-up.sh, argocd-up.sh, kyverno-up.sh,
observability-up.sh, minio-up.sh, minio-cluster-up.sh, trivy-operator-up.sh,
vault/00-secrets-engines.sh) read LAB_DOMAIN — environment variable first, then lab.env,
then the neutral default — and substitute the domain on the fly:
No versioned file is rewritten: git status stays clean.
⚠️ Manifests applied by hand (without a *-up.sh) get no such substitution:
wordpress-example/wordpress-mariadb.yaml, longhorn/httproute.yaml,
vault-cluster/httproute.yaml, vault-secret-operator/k8s/30-pki-tls.yaml. Either edit
them, or pipe them through the same sed:
ℹ️ SELF_SIGNED decides how that domain gets its certificate (see lab.env.example).
true — the default — signs a wildcard with openssl under a local CA
(self-signed/): no real domain, no token, no quota, and the
domain never has to resolve publicly. false switches to
cert-manager/ + Let's Encrypt, and only then do the three ACME
variables matter: LAB_DNS_ZONE (zone of the DNS-01 solver; default = the last 2 labels of
LAB_DOMAIN), LAB_ACME_EMAIL (Let's Encrypt account; default admin@<zone>) and
LAB_ACME_ISSUER — staging (default, untrusted cert) or prod (trusted, but capped at
5 certificates per week for the same wildcard, and every vagrant destroy burns one).
Installs only the 4 components above, idempotent (helm upgrade --install), re-runnable
without breaking anything.
Component
Pinned version
Where the pin lives
Cilium
1.19.6
cilium/cilium-up.sh
Envoy Gateway
1.8.3
platform-up.sh (ENVOY_GW_VERSION)
metrics-server
v0.9.0
metric-server.yaml
cert-manager — only if SELF_SIGNED=false
v1.20.2
platform-up.sh (CERT_MANAGER_VERSION)
ℹ️ With the default SELF_SIGNED=true, step [4/4] runs
self-signed/selfsigned-up.sh instead and cert-manager is never
installed — no chart, no CRDs, no Cloudflare Secret.
ℹ️ metrics-server tuned for Talos: --kubelet-insecure-tls + secure port 10250, so it
does not require a kubelet CSR approver. Check: kubectl top nodes.
⚠️ Stay on the /32 (or fence it with an ACL): a /24 would also expose the Talos
(:50000) and Kubernetes (:6443) APIs.
Name + TLS — public Cloudflare wildcard *.talos.lab.example.io → 192.168.56.200, in
DNS-only (grey cloud): the Cloudflare proxy cannot reach a private 192.168.56.x IP.
TLS is therefore terminated by Envoy, not by Cloudflare → the Gateway must carry a
publicly trusted certificate (Let's Encrypt, see cert-manager/). A Cloudflare Origin
CA certificate would be rejected by browsers.
Two default StorageClasses.longhorn/values.yaml sets persistence.defaultClass: true and local-path-storage.yaml sets the is-default-class: "true" annotation. With
both addons installed ⇒ a PVC without an explicit storageClassName lands on the most
recently created SC, non-deterministically. Always name your SC.
The repo's own Kyverno policies are violated by the repo.require-requests-limits
demands a limits.cpu that the in-house manifests never set (deliberate choice: no CPU
throttling), and require-labels expects app.kubernetes.io/name where they use app:.
The report is therefore noisy by construction — see
kyverno/.
Metric emitters are off by default.serviceMonitor/podMonitor are false in
trivy-operator, CloudNativePG and node-problem-detector: Prometheus scrapes nothing from
them until you flip them on after installing observability.
Never lower CP_MEM below 3072. Stacking these addons on 2 GB control planes starves
etcd. lab.env.example ships 4096, which is what observability/ requires.
A git add -A can publish local addon secrets._k8s/databasement/ is gitignored for
that very reason — check git status before committing.
Tout ce qui s'installe après le bootstrap du cluster, avec kubectl/helm depuis
l'hôte — y compris le CNI, sauf si tu as choisi CNI=flannel (le seul que Talos
installe lui-même, au bootstrap).
Le dossier se lit en deux temps : une plateforme de base (4 briques, un seul script),
puis des addons indépendants que tu ajoutes à la carte, chacun dans son dossier avec
son *-up.sh.
# 1. Bootstrap en CNI=cilium (défaut du dépôt) : Talos ne pose aucun CNI, cette couche siCNI=cilium./talos/cluster-up.sh
# 2. La plateforme de base : Cilium → Envoy Gateway → metrics-server → wildcard TLS
./_k8s/platform-up.sh
# 3. Les addons, à la carte
./_k8s/longhorn/longhorn-up.sh# stockage bloc (StorageClass longhorn)
./_k8s/vault-cluster/vault-up.sh# Vault HA — exige longhorn-up.sh d'abord
./_k8s/argocd/argocd-up.sh
./_k8s/chaos-kube/chaoskube-up.sh# optionnel : chaos, supprime 1 pod/heure
⚠️ CNI=cilium est le seul choix « tout allumé ». Cette couche a besoin d'un Service
LoadBalancer qui obtienne réellement une IP : c'est ce que fait l'annonce L2 (ARP) de
Cilium. Avec flannel, calico ou none, le Gateway reste en EXTERNAL-IP <pending> et
aucune UI n'est joignable.
CNI (dans lab.env) est lu à deux endroits : talos/cluster-up.sh applique le patch
talos/cni-<CNI>.yaml au bootstrap, puis platform-up.sh installe le CNI quand ce n'est
pas Talos qui l'a fait.
Chaque maillon suppose le précédent : pas d'IP LoadBalancer sans annonceur L2, pas d'HTTPS
sans le Gateway, pas d'UI sans certificat sur l'écouteur :443.
cluster Ready (Talos a bootstrapé, CNI selon lab.env)
│
├─ 1. CNI cilium/ (défaut, + pool L2 → IP LoadBalancer .200)
│ ou calico/ (CNI seul) ou flannel (déjà posé) ou rien
├─ 2. envoy-gateway/ contrôleur Envoy + main-gateway (écouteurs :80 et :443)
├─ 3. metric-server API metrics.k8s.io (kubectl top, HPA)
└─ 4. wildcard TLS *.talos.lab.example.io — deux modes, selon SELF_SIGNED
│ true (défaut) → self-signed/ openssl, AC locale, pas de cert-manager
│ false → cert-manager/ Let's Encrypt DNS-01 Cloudflare
│
└─ addons : stockage → bases → secrets → observabilité → sécurité
C'est exactement l'ordre de platform-up.sh ([1/4] → [4/4]) : metrics-server avant le
certificat. Les deux modes TLS remplissent le même Secret
(wildcard-<LAB_DOMAIN en tirets>-tls), donc aucun addon n'a jamais à savoir lequel tu as
choisi — tous se contentent d'accrocher une HTTPRoute à l'écouteur https.
openssl sur l'hôte — seulement si SELF_SIGNED=true (le défaut)
génère le wildcard TLS
openssl version
CLOUDFLARE_API_TOKEN dans lab.env — seulement si SELF_SIGNED=false
DNS-01 du wildcard TLS
lu par platform-up.sh
LAB_DOMAIN dans lab.env
domaine des UI (wildcard TLS + HTTPRoute)
sed -n 's/^LAB_DOMAIN=//p' lab.env
StorageClass selon l'addon
local-path, longhorn ou longhorn-r1
kubectl get sc
💡 Les StorageClass ne sont pas interchangeables : longhorn-r1 (1 réplica) est le socle
des applis qui répliquent déjà au niveau applicatif (PostgreSQL, Vault), longhorn
(3 réplicas) pour le reste, local-path pour l'éphémère. Voir
longhorn/ et local-path-storage/.
Aucun fichier versionné n'est réécrit : git status reste propre.
⚠️ Les manifestes appliqués à la main (sans *-up.sh) ne bénéficient pas de cette
substitution : wordpress-example/wordpress-mariadb.yaml, longhorn/httproute.yaml,
vault-cluster/httproute.yaml, vault-secret-operator/k8s/30-pki-tls.yaml. Soit tu
les édites, soit tu passes par le même sed :
ℹ️ SELF_SIGNED décide comment ce domaine obtient son certificat (cf.
lab.env.example). true — le défaut — signe un wildcard avec openssl sous une AC
locale (self-signed/) : pas de domaine réel, pas de token, pas
de quota, et le domaine n'a jamais besoin de résoudre publiquement. false bascule sur
cert-manager/ + Let's Encrypt, et c'est seulement là que les
trois variables ACME comptent : LAB_DNS_ZONE (zone du solveur DNS-01 ; défaut = les
2 derniers labels de LAB_DOMAIN), LAB_ACME_EMAIL (compte Let's Encrypt ; défaut
admin@<zone>) et LAB_ACME_ISSUER — staging (défaut, cert non trusté) ou prod
(trusté, mais plafonné à 5 certificats par semaine pour un même wildcard, et chaque
vagrant destroy en brûle un).
Installe uniquement les 4 briques ci-dessus, idempotent (helm upgrade --install),
relançable sans casse.
Brique
Version épinglée
Source du pin
Cilium
1.19.6
cilium/cilium-up.sh
Envoy Gateway
1.8.3
platform-up.sh (ENVOY_GW_VERSION)
metrics-server
v0.9.0
metric-server.yaml
cert-manager — seulement si SELF_SIGNED=false
v1.20.2
platform-up.sh (CERT_MANAGER_VERSION)
ℹ️ Avec le défaut SELF_SIGNED=true, l'étape [4/4] lance
self-signed/selfsigned-up.sh à la place et cert-manager
n'est jamais installé — ni chart, ni CRD, ni Secret Cloudflare.
ℹ️ metrics-server adapté Talos : --kubelet-insecure-tls + port sécurisé 10250,
pour ne pas exiger d'approbateur de CSR kubelet. Vérif : kubectl top nodes.
⚠️ Reste sur le /32 (ou cadre par ACL) : un /24 exposerait aussi les API Talos
(:50000) et Kubernetes (:6443).
Nom + TLS — wildcard Cloudflare public *.talos.lab.example.io → 192.168.56.200, en
DNS-only (nuage gris) : le proxy Cloudflare ne peut pas joindre une IP privée
192.168.56.x. Le TLS est donc terminé par Envoy, pas par Cloudflare → le Gateway
doit porter un certificat publiquement trusté (Let's Encrypt, cf. cert-manager/).
Un certificat Cloudflare Origin CA serait rejeté par les navigateurs.
Deux StorageClass par défaut.longhorn/values.yaml pose persistence.defaultClass: true et local-path-storage.yaml l'annotation is-default-class: "true". Les deux
addons installés ⇒ un PVC sans storageClassName explicite atterrit sur la SC la plus
récemment créée, de façon non déterministe. Nomme toujours ta SC.
Les policies Kyverno du dépôt sont violées par le dépôt.require-requests-limits
exige limits.cpu que les manifestes maison ne posent jamais (choix délibéré : pas de
throttling CPU), et require-labels attend app.kubernetes.io/name là où ils utilisent
app:. Le rapport est donc bruyant par construction — voir
kyverno/.
Les émetteurs de métriques sont coupés par défaut.serviceMonitor/podMonitor sont
à false chez trivy-operator, CloudNativePG et node-problem-detector : Prometheus ne
collecte rien d'eux tant que tu ne les as pas basculés après l'install d'observability.
Ne descends jamais CP_MEM sous 3072. Empiler ces addons sur des control planes à
2 Go affame etcd. lab.env.example livre 4096, ce qu'observability/ exige.
Un git add -A peut publier des secrets d'addons locaux._k8s/databasement/ est
désormais gitignoré pour cette raison — vérifie git status avant de commiter.
🐝cilium/ — CNI, LoadBalancer IPs and L2 announcement (ARP)
The networking component of the lab. Cilium provides the CNI (without it the nodes stay
NotReady) and also plays the "cloud provider" role: it hands type: LoadBalancer Services a
real IP from the host-only network192.168.56.0/24 and announces it over ARP. That
mechanism is what produces the 192.168.56.200 VIP of the Envoy entry point — no MetalLB
involved.
Chart cilium/cilium1.19.6, pinned in the script via CILIUM_VERSION (overridable:
CILIUM_VERSION=1.19.7 ./_k8s/cilium/cilium-up.sh). Idempotent (helm upgrade --install +
kubectl apply). ../platform-up.sh calls it as step [1/4] — so there is nothing to run
here if you go through the full platform.
⚠️ Do not take §9 of the root README as the installation reference. It shows the "manual"
helm upgrade to explain who installs the CNI, but without --version (you get the latest
published release, not the one validated here) and without applying cilium-l2.yml — so
without an IP pool: the Gateway would stay at EXTERNAL-IP <pending>. The source of truth is
cilium-up.sh.
the key one: pins the host-only NIC. Without it, Cilium picks the NIC carrying the default route (NAT 10.0.2.15, identical on every VM) → unusable VTEP and ARP
routingMode=tunnel + tunnelProtocol=vxlan
encapsulation between nodes, no route to add on the VirtualBox side
ipam.mode=kubernetes
PodCIDRs come from Kubernetes (the ones in the Talos config)
l2announcements.enabled=true
enables the controller that answers ARP; without it the CiliumL2AnnouncementPolicy is ignored
externalIPs.enabled=true
support for Service externalIPs
kubeProxyReplacement=false
we keep the Talos kube-proxy (see ⚠️ Pitfalls to replace it)
envoy.enabled=false
no need for Cilium's embedded Envoy: the lab uses the ../envoy-gateway/ controller, a separate component
reserves the .200 → .230 range; every LoadBalancer Service draws from it
CiliumL2AnnouncementPolicyl2-lb-workers
announces those IPs over ARP on enp0s8, from the workers only (control planes are excluded by the nodeSelector)
Why these choices:
.200-.230 range: clear of the node IPs (CP .10/.20/.30, workers .101+), of the API
VIP .5 and of the gateway .1. Keep it aligned if you change the IP plan in lab.env.
enp0s8 interface: the host-only NIC, the only address through which the host can
reach the VMs (adapt the ^enp0s8$ regex if your NICs have other names).
Workers only: keeps a control plane from answering ARP for the VIP. On a single-node
topology (no worker), you have to drop the nodeSelector, otherwise nobody announces anything.
kubectl-nkube-systemgetpods-lk8s-app=cilium# one agent per node, Running
kubectlgetnodes# all Ready
kubectlgetciliumloadbalancerippool# lb-pool-56, DISABLED=false, IPS AVAILABLE
kubectlgetciliuml2announcementpolicy# l2-lb-workers
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP = 192.168.56.200
ping-c1192.168.56.200# from the host: ARP must answer
Service stuck at EXTERNAL-IP: <pending> → missing pool (cilium-l2.yml not applied),
exhausted range, or l2announcements not enabled at install time (the typical case when you
followed §9 of the root README instead of cilium-up.sh).
VIP that answers ping from the host but not from a Tailscale peer → expected: ARP does
not cross a router. You need --advertise-routes on the host
(see ../README.md).
--set autoDirectNodeRoutes=true (or ipv4NativeRoutingCIDR) is forbidden here: those are
native routing options, incompatible with tunnel mode. The agent exits fatal
("auto-direct-node-routes cannot be used with tunneling") and loops in CrashLoopBackOff.
Replacing kube-proxy takes two consistent changes: proxy.disabled: true in
talos/cni-none.yamlandkubeProxyReplacement=true + k8sServiceHost=192.168.56.5k8sServicePort=6443 on the Helm side. Done halfway, the cluster loses its Services.
Do not re-run the script to "refresh" a cluster that is running a demo without reading the
Helm diff: changing routingMode or devices cuts traffic while the agents redeploy.
Alpha API: CiliumL2AnnouncementPolicy only exists as cilium.io/v2alpha1 on 1.19.6 (the
pool itself moved to v2 — v2alpha1 is marked deprecated there). Re-check on a major version
bump: kubectl get crd ciliuml2announcementpolicies.cilium.io -o yaml.
🐝cilium/ — CNI, IP LoadBalancer et annonce L2 (ARP)
La brique réseau du lab. Cilium fournit le CNI (sans lui les nodes restent NotReady)
et joue en plus le rôle de « cloud provider » : il attribue aux Services type: LoadBalancer
une vraie IP du réseau host-only192.168.56.0/24 et l'annonce en ARP. C'est ce
mécanisme qui produit le VIP 192.168.56.200 du point d'entrée Envoy — sans MetalLB.
Chart cilium/cilium1.19.6, épinglé dans le script via CILIUM_VERSION (surchargeable :
CILIUM_VERSION=1.19.7 ./_k8s/cilium/cilium-up.sh). Idempotent (helm upgrade --install +
kubectl apply). ../platform-up.sh l'appelle en étape [1/4] — tu n'as donc rien à lancer
ici si tu déroules la plateforme complète.
⚠️ Ne prends pas le §9 du README racine comme référence d'installation. Il montre le
helm upgrade « à la main » pour expliquer qui installe le CNI, mais sans --version
(tu prends la dernière release publiée, pas celle validée ici) et sans appliquer
cilium-l2.yml — donc sans pool d'IP : la Gateway resterait en EXTERNAL-IP <pending>.
La source de vérité, c'est cilium-up.sh.
le point clé : épingle la carte host-only. Sans ça, Cilium prend la carte de la route par défaut (NAT 10.0.2.15, identique sur chaque VM) → VTEP et ARP inutilisables
routingMode=tunnel + tunnelProtocol=vxlan
encapsulation entre nodes, aucune route à poser côté VirtualBox
ipam.mode=kubernetes
les PodCIDR viennent de Kubernetes (ceux de la config Talos)
l2announcements.enabled=true
active le contrôleur qui répond à l'ARP ; sans lui la CiliumL2AnnouncementPolicy est ignorée
externalIPs.enabled=true
prise en charge des externalIPs de Services
kubeProxyReplacement=false
on garde le kube-proxy de Talos (cf. ⚠️ Pièges pour le remplacer)
envoy.enabled=false
pas besoin de l'Envoy embarqué de Cilium : le lab utilise le contrôleur ../envoy-gateway/, un composant distinct
réserve la plage .200 → .230 ; chaque Service LoadBalancer pioche dedans
CiliumL2AnnouncementPolicyl2-lb-workers
annonce en ARP ces IP sur enp0s8, depuis les workers uniquement (les control planes sont exclus par le nodeSelector)
Pourquoi ces choix :
Plage .200-.230 : hors des IP de nodes (CP .10/.20/.30, workers .101+), de la VIP
d'API .5 et de la passerelle .1. À garder alignée si tu changes le plan d'adressage de
lab.env.
Interface enp0s8 : la carte host-only, seule adresse par laquelle l'hôte peut
joindre les VMs (le regex ^enp0s8$ est à adapter si tes cartes ont d'autres noms).
Workers seulement : évite qu'un control plane réponde à l'ARP du VIP. Sur une topologie
single node (aucun worker), il faut retirer le nodeSelector, sinon plus personne n'annonce.
kubectl-nkube-systemgetpods-lk8s-app=cilium# un agent par node, Running
kubectlgetnodes# tous Ready
kubectlgetciliumloadbalancerippool# lb-pool-56, DISABLED=false, IPS AVAILABLE
kubectlgetciliuml2announcementpolicy# l2-lb-workers
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP = 192.168.56.200
ping-c1192.168.56.200# depuis l'hôte : l'ARP doit répondre
Service coincé en EXTERNAL-IP: <pending> → pool absent (cilium-l2.yml non appliqué),
plage épuisée, ou l2announcements non activé à l'install (cas typique quand on a suivi le
§9 du README racine au lieu de cilium-up.sh).
VIP qui répond au ping depuis l'hôte mais pas depuis un peer Tailscale → normal :
l'ARP ne traverse pas un routeur. Il faut --advertise-routes sur l'hôte
(cf. ../LISEZ-MOI.md).
--set autoDirectNodeRoutes=true (ou ipv4NativeRoutingCIDR) est interdit ici : ce sont
des options de routage natif, incompatibles avec le mode tunnel. L'agent sort en fatal
(« auto-direct-node-routes cannot be used with tunneling ») et boucle en CrashLoopBackOff.
Remplacer kube-proxy demande deux changements cohérents : proxy.disabled: true dans
talos/cni-none.yamletkubeProxyReplacement=true + k8sServiceHost=192.168.56.5k8sServicePort=6443 côté Helm. À moitié fait, le cluster perd ses Services.
Ne relance pas le script pour « rafraîchir » un cluster en production de démo sans lire
le diff Helm : un changement de routingMode ou de devices coupe le trafic le temps du
redéploiement des agents.
API alpha : CiliumL2AnnouncementPolicy n'existe qu'en cilium.io/v2alpha1 sur 1.19.6
(le pool, lui, est passé en v2 — v2alpha1 y est marqué déprécié). À revérifier lors d'une
montée de version majeure : kubectl get crd ciliuml2announcementpolicies.cilium.io -o yaml.
🐆calico/ — alternative CNI: pod network + NetworkPolicy, no LoadBalancer IPs
The lab's third CNI choice, next to flannel (installed by Talos) and cilium (the default).
Calico is installed by the Tigera operator and covers the pod network, routing and
NetworkPolicy. It does not take over the "cloud provider" role that Cilium fills on top:
no LoadBalancer Service IP, hence no 192.168.56.200 VIP — you need MetalLB alongside. Read
the 🎯 section before choosing.
CNI: pod network over VXLAN on 10.244.0.0/16, natOutgoing to reach the Internet.
It is what takes the nodes from NotReady to Ready.
NetworkPolicy: the standard networking.k8s.io/v1 ones and the
NetworkPolicy/GlobalNetworkPolicy of projectcalico.org/v3 (order, tiers, explicit deny,
HostEndpoint…). That is the real reason this directory exists: working on micro-segmentation
with the reference implementation.
projectcalico.org/v3 API: the calico-apiserver is enabled, so Calico objects are read
and written with kubectl, without installing calicoctl.
What Calico does not do — and why that blocks you#
⚠️ Calico does NOT announce LoadBalancer Service IPs on this lab. It can only do it over
BGP (serviceLoadBalancerIPs in a BGPConfiguration), which requires a peer router to
establish a session with. On a VirtualBox host-only network, that router does not exist. And
Calico has no equivalent of Cilium's L2/ARP announcement
(CiliumLoadBalancerIPPool + CiliumL2AnnouncementPolicy). That is why installation.yaml
sets bgp: Disabled: it is not an oversight, it is a fact.
Concrete, not theoretical consequence: with CNI=calicoand nothing else,
What happens
Visible effect
The Envoy Gateway Service never gets an external IP
⚠️ only with the eBPF dataplane, ruled out here (see ⚠️ Pitfalls)
Flow observability
✅ Hubble (relay + UI) installed
⚠️ Whisker + Goldmane shipped by the chart, disabled by default here
Ready to use in THIS lab
✅ platform-up.sh chains everything
⚠️ two manual steps are left to you
💡 Recommendation: keep Cilium as the lab default. Calico is here to compare CNIs and to
work on NetworkPolicy, not to light up a complete lab without extra work.
Chart projectcalico/tigera-operatorv3.32.1 (repo https://docs.tigera.io/calico/charts),
pinned in the script via CALICO_VERSION. Idempotent (helm upgrade --install +
kubectl apply), safe to re-run.
Overridable variables:
Variable
Default
Role
CALICO_VERSION
v3.32.1
version of the chart and of Calico (the chart aligns them)
NETWORK
NETWORK from lab.env, else 192.168.56
builds the host-only CIDR used by address autodetection
POD_CIDR
10.244.0.0/16
IPPool CIDR — must stay equal to the Talos podSubnets
HOSTONLY_CIDR
${NETWORK}.0/24
only touch it if your host-only network is not a /24
Guardrails: binaries, /readyz, refusal if another CNI is already there, and refusal if
POD_CIDR diverges from the podSubnets read in _out/controlplane.yaml.
namespace.yaml — the tigera-operator namespace, created before
the chart because it carries the PodSecurity privileged labels. Helm's --create-namespace
sets no label, and Talos's baseline default rejects the operator pod (see ⚠️ Pitfalls).
tigera-operator chart in that namespace. The operator runs in hostNetwork: it starts
without a CNI, which is what makes bootstrapping possible.
Waits for the operator.tigera.io CRDs: the operator is started with -manage-crds=true,
so it is the one creating installationsandapiservers.operator.tigera.io. Applying
a CR before that fails with "no matches for kind …".
kubectl apply of installation.yaml, with both CIDRs substituted
on the fly (same mechanism as LAB_DOMAIN in ../platform-up.sh), then of
apiserver.yaml which deploys the calico-apiserver.
Bounded waits: the calico-system/calico-node DaemonSet created, rollout status
(600 s, the time of the first pull on 8 VMs), then all nodes Ready (300 s). Each one fails
with an error carrying the diagnostic command to run — no || true anywhere.
Summary + a yellow reminder of the two steps still missing for the lab UIs.
The chart renders four custom resources (Installation, APIServer, Goldmane, Whisker)
and ships no crds/ directory — the CRDs are created by the operator at runtime
(-manage-crds=true). So every one of them has to be disabled at install time, otherwise Helm
fails before it even creates the namespace:
Error: unable to build kubernetes objects from release manifest: resource mapping not found
for name: "default" ... no matches for kind "APIServer" in version "operator.tigera.io/v1"
ensure CRDs are installed first
--set
Why
installation.enabled=false
the chart can generate the Installation CR itself; we pull it out into installation.yaml to get one readable file and one owner of the object (not Helm andkubectl apply)
apiServer.enabled=false
same reason, CR moved to apiserver.yaml and applied once the CRDs exist. The calico-apiserveris deployed: it exposes projectcalico.org/v3 → Calico objects via kubectl, no calicoctl needed
goldmane.enabled=false + whisker.enabled=false
the flow aggregator + UI shipped by Calico 3.32, turned off to keep the lab light (VM RAM is counted, see lab.env). Turning them back on means extracting their CRs the same way — --set goldmane.enabled=true alone reintroduces the bootstrap failure above
THE key one: forces the host-only address (see ⚠️ Pitfalls)
ipPools[0].cidr
10.244.0.0/16
identical to the Talos podSubnets
ipPools[0].encapsulation
VXLAN
unconditional encapsulation; VXLANCrossSubnet would fall back to direct routing between nodes of the same /24, which assumes the host-only switch forwards packets with a "pod" source IP — unverified. Same choice as flannel and Cilium
calicoNetwork.bgp
Disabled
no BGP peer on a host-only network ⇒ BIRD is useless (and therefore no service IP announcement)
calicoNetwork.linuxDataplane
Iptables
we keep the Talos kube-proxy, like the Cilium install (kubeProxyReplacement=false)
calicoNetwork.mtu
1450
1500 (host-only) − 50 (IPv4 VXLAN headers)
kubeletVolumePluginPath
None
Talos adaptation: disables the Calico CSI driver, as the Sidero guide requires
flexVolumePath
None
Talos adaptation: without it the operator adds a flexvol-driver init container whose DirectoryOrCreate hostPath targets /usr/libexec/kubernetes/kubelet-plugins/volume/exec/ — /usr is read-only on Talos, so the pod never starts
kubectl-ntigera-operatorgetpods# tigera-operator Running
kubectlgettigerastatus# calico / apiserver: AVAILABLE=True
kubectl-ncalico-systemgetpods-owide# one calico-node per node + typha
kubectlgetnodes# all Ready
kubectlgetinstallationdefault-oyaml# the CR as the operator completed it
kubectlgetippools.projectcalico.orgdefault-ipv4-ippool-oyaml# cidr + vxlanMode Always
The check that really matters: the address picked by each node must be in 192.168.56.x,
never10.0.2.15.
Then a cross-node traffic test (this is where the NAT NIC pitfall shows up):
kubectlrunt1--image=busybox--restart=Never--command--sleep3600
kubectlrunt2--image=busybox--restart=Never--command--sleep3600
kubectlgetpods-owide# check they are on 2 different nodes
kubectlexect1--ping-c3"$(kubectlgetpodt2-ojsonpath='{.status.podIP}')"
kubectlexect1--nslookupkubernetes.default# DNS = CoreDNS, often on another node
kubectldeletepodt1t2
Then an IPAddressPool + an L2Advertisement (metallb.io/v1beta1) covering the range.
ℹ️ PodSecurity: the MetalLB speaker runs in hostNetwork with NET_RAW. The Talos
default is baseline, which rejects such pods. So you have to set
pod-security.kubernetes.io/enforce: privileged on the metallb-system namespace — same
recipe as ../observability/namespace.yaml.
⚠️ As long as that line is there, MetalLB will not serve the Service. A
loadBalancerClass tells Kubernetes "only this controller may handle this Service": MetalLB
will ignore it and the IP will stay <pending> even with a valid pool. You have to delete
the line (any announcer then takes over) or replace it with the class of the announcer you
chose.
Once both points are done:
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP = 192.168.56.200
ping-c1192.168.56.200# from the host: ARP must answer
NAT NIC elected for the tunnels — the house pitfall, see CLAUDE.md. Every VM has
enp0s3 (NAT, 10.0.2.15, the same IP on all VMs, and the one carrying the default route)
and enp0s8 (host-only, 192.168.56.x). Calico's default autodetection (firstFound) follows
the default route ⇒ every node declares itself as 10.0.2.15, every VXLAN VTEP points at an
isolated NAT, and cross-node pod traffic + DNS are broken. Hence
nodeAddressAutodetectionV4.cidrs: ["192.168.56.0/24"]. Same problem, same countermeasure as
--iface-can-reach (flannel) and devices=enp0s8 (Cilium).
PodSecurity baseline blocks the operator, and it fails SILENTLY. The operator needs
hostNetwork (that is what lets it start with no CNI at all) and a /var/lib/calico hostPath;
Talos enforces baseline by default, which rejects both. helm --create-namespace sets no PSS
label, so without namespace.yaml the Deployment is created but the
ReplicaSet cannot create a single pod. The trap is the symptom: kubectl -n tigera-operator get pods returns nothing at all — not a failing pod, zero pods — and the script dies on the
rollout status timeout. The cause is only visible in the ReplicaSet events:
kubectl -n tigera-operator describe rs. Same recipe as
../observability/namespace.yaml.
💡 If you already hit the failure, fixing the labels is not enough: the ReplicaSet is in
exponential backoff and can idle past the 300 s timeout. Kick it with
kubectl -n tigera-operator rollout restart deploy/tigera-operator, then re-run the script.
IPPool CIDR ≠ Talos podSubnets = silently broken pod network. The script refuses to
continue if it detects the mismatch in _out/controlplane.yaml, but if you change one, change
the other.
Changing CNI is NOT a live switch. Going from Cilium to Calico (or the other way round) on
a live cluster leaves contradictory routes, iptables/eBPF rules and /etc/cni/net.d files. The
procedure is: vagrant destroy → CNI=calico in lab.env → vagrant up →
./talos/cluster-up.sh → ./_k8s/calico/calico-up.sh. The script's guardrail is there to stop
you doing it by mistake, not to make the operation possible.
No Cilium loadBalancerClass: see the 🌐 section above. It is the #1 cause of an
EXTERNAL-IP <pending> that persists after installing MetalLB.
eBPF dataplane: tempting, ruled out. The Sidero guide recommends it, but it requires
bpfNetworkBootstrap: Enabled, kubeProxyManagement: Enabled and a FelixConfiguration with
cgroupV2Path: /sys/fs/cgroup (Talos has no usable /var for that) — and it is broken on some
Talos versions (BPF program load failed on calico_sendmsg_v46, see
siderolabs/talos#12221). Too many moving
parts for the lab's "comparison" CNI.
kubectl delete -f installation.yaml does not cleanly uninstall Calico: the operator
deletes calico-node and every node drops to NotReady at once, pods included. Use
helm uninstall (see 🧹) or, better, destroy the lab.
MetalLB L2: a single node answers ARP per IP. Like the CiliumL2AnnouncementPolicy, this
is not load balancing: one speaker is elected per address, all the VIP traffic enters through
that node, then kube-proxy spreads it. A speaker failover takes a few seconds (the time to
re-ARP) — expected, not an incident.
The chart ships a pre-delete hook (Job tigera-operator-uninstall) that cleans up the CR
before removing the operator:
helmuninstallcalico-ntigera-operator
⚠️ This cuts the CNI: every node goes back to NotReady and the pod network disappears.
Do not do it "just to see" on a lab that hosts anything. To get back to Cilium, destroy and
rebuild the cluster (vagrant destroy → CNI=cilium → cluster-up.sh →
_k8s/platform-up.sh).
🐆calico/ — CNI alternatif : réseau pod + NetworkPolicy, sans IP LoadBalancer
Troisième choix de CNI du lab, à côté de flannel (posé par Talos) et de cilium
(le défaut). Calico est installé par l'opérateur Tigera et couvre le réseau pod,
le routage et les NetworkPolicy. Il ne remplace pas le rôle de « cloud provider »
que Cilium assure en plus : aucune IP de Service LoadBalancer, donc aucun VIP
192.168.56.200 — il faut MetalLB à côté. Lis la section 🎯 avant de choisir.
CNI : réseau pod en VXLAN sur 10.244.0.0/16, natOutgoing pour sortir vers
Internet. C'est lui qui fait passer les nodes de NotReady à Ready.
NetworkPolicy : les networking.k8s.io/v1 standard et les
NetworkPolicy/GlobalNetworkPolicy de projectcalico.org/v3 (ordre, tiers, deny
explicite, HostEndpoint…). C'est la vraie raison d'être de ce dossier : travailler la
micro-segmentation avec l'implémentation de référence.
API projectcalico.org/v3 : le calico-apiserver est activé, donc les objets Calico
se lisent et s'écrivent au kubectl, sans installer calicoctl.
Ce que Calico ne fait pas — et pourquoi c'est bloquant#
⚠️ Calico n'annonce PAS les IP de Service LoadBalancer sur ce lab. Il ne sait le
faire qu'en BGP (serviceLoadBalancerIPs d'une BGPConfiguration), ce qui suppose un
routeur pair avec qui établir une session. Sur un réseau host-only VirtualBox, ce
routeur n'existe pas. Et Calico n'a aucun équivalent de l'annonce L2/ARP de Cilium
(CiliumLoadBalancerIPPool + CiliumL2AnnouncementPolicy). C'est pour ça que
installation.yaml pose bgp: Disabled : ce n'est pas un oubli, c'est un constat.
Conséquence concrète, pas théorique : avec CNI=calicoet rien d'autre,
Ce qui se passe
Effet visible
Le Service du Gateway Envoy ne reçoit jamais d'IP externe
⚠️ seulement en dataplane eBPF, écarté ici (cf. ⚠️ Pièges)
Observabilité des flux
✅ Hubble (relay + UI) installés
⚠️ Whisker + Goldmane livrés par le chart, désactivés par défaut ici
Prêt à l'emploi dans CE lab
✅ platform-up.sh enchaîne tout
⚠️ deux étapes manuelles restent à ta charge
💡 Recommandation : garde Cilium comme défaut du lab. Calico est là pour comparer
les CNI et pour travailler les NetworkPolicy, pas pour allumer un lab complet sans
travail supplémentaire.
Chart projectcalico/tigera-operatorv3.32.1 (repo https://docs.tigera.io/calico/charts),
épinglé dans le script via CALICO_VERSION. Idempotent (helm upgrade --install +
kubectl apply), relançable sans casse.
Variables surchargeables :
Variable
Défaut
Rôle
CALICO_VERSION
v3.32.1
version du chart et de Calico (le chart les aligne)
NETWORK
NETWORK de lab.env, sinon 192.168.56
construit le CIDR host-only de l'autodétection d'adresse
POD_CIDR
10.244.0.0/16
CIDR de l'IPPool — doit rester égal à podSubnets de Talos
HOSTONLY_CIDR
${NETWORK}.0/24
à ne toucher que si ton host-only n'est pas un /24
Garde-fous : binaires, /readyz, refus si un autre CNI est déjà là, et refus si
POD_CIDR diverge du podSubnets lu dans _out/controlplane.yaml.
namespace.yaml — le namespace tigera-operator, créé avant le
chart parce qu'il porte les labels PodSecurity privileged. Le --create-namespace de Helm
ne pose aucun label, et le défaut baseline de Talos refuse le pod de l'opérateur
(cf. ⚠️ Pièges).
Chart tigera-operator dans ce namespace. L'opérateur tourne en hostNetwork : il
démarre sans CNI, c'est ce qui rend l'amorçage possible.
Attente des CRD operator.tigera.io : l'opérateur est lancé avec -manage-crds=true,
c'est donc lui qui crée installationsetapiservers.operator.tigera.io. Appliquer
une CR avant ça échoue sur « no matches for kind … ».
kubectl apply de installation.yaml, avec les deux CIDR
substitués à la volée (même mécanique que LAB_DOMAIN dans ../platform-up.sh), puis de
apiserver.yaml qui déploie le calico-apiserver.
Attentes bornées : DaemonSet calico-system/calico-node créé, rollout status
(600 s, le temps du premier pull sur 8 VM), puis tous les nodes Ready (300 s).
Chacune échoue en erreur avec la commande de diagnostic à lancer — aucun || true.
Résumé + rappel en jaune des deux étapes manquantes pour les UI du lab.
Le chart rend quatre custom resources (Installation, APIServer, Goldmane, Whisker)
et ne livre aucun dossier crds/ — les CRD sont créées par l'opérateur à l'exécution
(-manage-crds=true). Les quatre doivent donc être coupées à l'installation, sinon Helm échoue
avant même de créer le namespace :
Error: unable to build kubernetes objects from release manifest: resource mapping not found
for name: "default" ... no matches for kind "APIServer" in version "operator.tigera.io/v1"
ensure CRDs are installed first
--set
Pourquoi
installation.enabled=false
le chart sait générer la CR Installation lui-même ; on la sort dans installation.yaml pour avoir un fichier relisible et un seul propriétaire de l'objet (pas Helm etkubectl apply)
apiServer.enabled=false
même raison, CR sortie dans apiserver.yaml et appliquée une fois les CRD présentes. Le calico-apiserverest bien déployé : il expose projectcalico.org/v3 → objets Calico au kubectl, pas besoin de calicoctl
goldmane.enabled=false + whisker.enabled=false
l'agrégateur de flux + l'UI livrés par Calico 3.32, coupés pour garder le lab léger (la RAM des VM est comptée, cf. lab.env). Les rallumer impose d'extraire leurs CR de la même façon — --set goldmane.enabled=true seul réintroduit l'échec d'amorçage ci-dessus
LE point clé : force l'adresse host-only (cf. ⚠️ Pièges)
ipPools[0].cidr
10.244.0.0/16
identique à podSubnets de Talos
ipPools[0].encapsulation
VXLAN
encapsulation inconditionnelle ; VXLANCrossSubnet retomberait sur du routage direct entre nodes du même /24, ce qui suppose que le commutateur host-only relaie des paquets à IP source « de pod » — non vérifié. Même choix que flannel et Cilium
calicoNetwork.bgp
Disabled
pas de pair BGP sur un host-only ⇒ BIRD ne sert à rien (et donc pas d'annonce d'IP de service)
calicoNetwork.linuxDataplane
Iptables
on garde le kube-proxy de Talos, comme l'install Cilium (kubeProxyReplacement=false)
calicoNetwork.mtu
1450
1500 (host-only) − 50 (entêtes VXLAN IPv4)
kubeletVolumePluginPath
None
adaptation Talos : coupe le driver CSI Calico, comme l'impose le guide Sidero
flexVolumePath
None
adaptation Talos : sans ça l'opérateur ajoute un init-container flexvol-driver dont le hostPath DirectoryOrCreate vise /usr/libexec/kubernetes/kubelet-plugins/volume/exec/ — /usr est en lecture seule sur Talos, le pod ne démarre jamais
Puis un test de trafic cross-node (c'est là que se voit le piège de la carte NAT) :
kubectlrunt1--image=busybox--restart=Never--command--sleep3600
kubectlrunt2--image=busybox--restart=Never--command--sleep3600
kubectlgetpods-owide# vérifie qu'ils sont sur 2 nodes différents
kubectlexect1--ping-c3"$(kubectlgetpodt2-ojsonpath='{.status.podIP}')"
kubectlexect1--nslookupkubernetes.default# DNS = CoreDNS, souvent sur un autre node
kubectldeletepodt1t2
Calico ne fournit pas d'IP de LoadBalancer : c'est à toi de poser un annonceur L2.
Ce dossier ne l'installe pas. Deux choses à faire, dans cet ordre.
1. Installer MetalLB en mode L2 sur la même plage que celle qu'utilise Cilium
(192.168.56.200 → 192.168.56.230, la première IP revenant à main-gateway) :
Puis un IPAddressPool + une L2Advertisement (metallb.io/v1beta1) couvrant la plage.
ℹ️ PodSecurity : le speaker MetalLB tourne en hostNetwork avec NET_RAW. Le
défaut Talos est baseline, qui refuse ces pods. Il faut donc poser
pod-security.kubernetes.io/enforce: privileged sur le namespace metallb-system —
même recette que ../observability/namespace.yaml.
⚠️ Tant que cette ligne est là, MetalLB ne servira pas le Service. Une
loadBalancerClass dit à Kubernetes « seul ce contrôleur a le droit de traiter ce
Service » : MetalLB l'ignorera et l'IP restera <pending> même avec un pool valide.
Il faut supprimer la ligne (n'importe quel annonceur prend alors la main) ou la
remplacer par la classe de l'annonceur retenu.
Une fois les deux points faits :
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP = 192.168.56.200
ping-c1192.168.56.200# depuis l'hôte : l'ARP doit répondre
Carte NAT élue pour les tunnels — le piège maison, cf. CLAUDE.md. Chaque VM a
enp0s3 (NAT, 10.0.2.15, la même IP sur toutes les VMs, et c'est elle qui porte la
route par défaut) et enp0s8 (host-only, 192.168.56.x). L'autodétection par défaut de
Calico (firstFound) suit la route par défaut ⇒ tous les nodes se déclarent en
10.0.2.15, tous les VTEP VXLAN pointent vers un NAT isolé, et le trafic pod cross-node
le DNS sont cassés. D'où nodeAddressAutodetectionV4.cidrs: ["192.168.56.0/24"]. Même
problème, même parade que --iface-can-reach (flannel) et devices=enp0s8 (Cilium).
PodSecurity baseline bloque l'opérateur, et ça échoue SILENCIEUSEMENT. L'opérateur a
besoin du hostNetwork (c'est ce qui lui permet de démarrer sans aucun CNI) et d'un hostPath
/var/lib/calico ; Talos impose baseline par défaut, qui refuse les deux. Le
helm --create-namespace ne pose aucun label PSS : sans namespace.yaml,
le Deployment est bien créé mais le ReplicaSet n'arrive à créer aucun pod. Le piège, c'est
le symptôme : kubectl -n tigera-operator get pods ne renvoie rien du tout — pas un pod
en erreur, zéro pod — et le script meurt sur le timeout du rollout status. La cause n'est
visible que dans les events du ReplicaSet : kubectl -n tigera-operator describe rs. Même
recette que ../observability/namespace.yaml.
💡 Si tu as déjà rencontré l'échec, corriger les labels ne suffit pas : le ReplicaSet est en
backoff exponentiel et peut rester inactif au-delà des 300 s de timeout. Relance-le avec
kubectl -n tigera-operator rollout restart deploy/tigera-operator, puis rejoue le script.
CIDR de l'IPPool ≠ podSubnets de Talos = réseau pod silencieusement cassé.
Le script refuse de continuer s'il détecte l'écart dans _out/controlplane.yaml, mais si
tu changes l'un, change l'autre.
Changer de CNI n'est PAS une bascule à chaud. Passer de Cilium à Calico (ou l'inverse)
sur un cluster vivant laisse des routes, des règles iptables/eBPF et des /etc/cni/net.d
contradictoires. La procédure est : vagrant destroy → CNI=calico dans lab.env →
vagrant up → ./talos/cluster-up.sh → ./_k8s/calico/calico-up.sh. Le garde-fou du
script est là pour t'empêcher de le faire par erreur, pas pour rendre l'opération possible.
Pas de loadBalancerClass Cilium : cf. la section 🌐 ci-dessus. C'est la cause n°1
d'un EXTERNAL-IP <pending> qui persiste après avoir installé MetalLB.
Dataplane eBPF : tentant, écarté. Le guide Sidero le recommande, mais il exige
bpfNetworkBootstrap: Enabled, kubeProxyManagement: Enabled et une FelixConfiguration
avec cgroupV2Path: /sys/fs/cgroup (Talos n'a pas de /var utilisable pour ça) — et il
est cassé sur certaines versions de Talos (BPF program load failed sur
calico_sendmsg_v46, cf. siderolabs/talos#12221).
Trop de pièces mobiles pour le CNI « de comparaison » du lab.
kubectl delete -f installation.yaml ne désinstalle pas proprement Calico : l'opérateur
supprime calico-node et tous les nodes retombent NotReady d'un coup, pods compris.
Utilise helm uninstall (cf. 🧹) ou, mieux, détruis le lab.
MetalLB L2 : une seule node répond à l'ARP par IP. Comme la
CiliumL2AnnouncementPolicy, ce n'est pas de l'équilibrage : un speaker est élu par
adresse, tout le trafic du VIP entre par ce node, puis kube-proxy répartit. Une bascule de
speaker prend quelques secondes (le temps du ré-ARP) — normal, pas un incident.
Le chart embarque un hook pre-delete (Job tigera-operator-uninstall) qui nettoie la CR
avant de retirer l'opérateur :
helmuninstallcalico-ntigera-operator
⚠️ Ça coupe le CNI : tous les nodes repassent NotReady et le réseau pod disparaît.
Ne le fais pas « pour voir » sur un lab qui héberge quelque chose. Pour revenir à Cilium,
détruis et reconstruis le cluster (vagrant destroy → CNI=cilium → cluster-up.sh →
_k8s/platform-up.sh).
🚪envoy-gateway/ — the cluster's HTTP(S) entry point
One VIP, two listeners, N applications.Envoy Gateway
(an implementation of the Gateway API) deploys an Envoy whose LoadBalancer Service picks
up the 192.168.56.200 VIP from the Cilium pool. The main-gatewayGateway exposes :80and:443 on it (wildcard TLS *.talos.lab.example.io), and every component plugs in with
an HTTPRoute.
🌐 talos.lab.example.io is the repo's NEUTRAL domain (it is public): platform-up.sh
replaces it with LAB_DOMAIN (lab.env) — hostname of the https listener and name of the
TLS Secret. See ../README.md.
Share the exposure: one IP, one certificate, one configuration point for every lab UI
(Argo CD, Vault, Longhorn, Grafana, Policy Reporter, WordPress…).
Do the Gateway API for real: GatewayClass → Gateway → HTTPRoute, with
cross-namespace attachment, filters and routing by path or by hostname.
Terminate TLS at the cluster edge: the backends speak plain HTTP.
⚠️ Do not confuse this with the Envoy embedded in Cilium (disabled here:
envoy.enabled=false, see ../cilium/README.md). Here Envoy is driven
by the Envoy Gateway controller, a component in its own right.
it is what gives the .200 IP to the Gateway's Service
kubectl get ciliumloadbalancerippool
A wildcard TLS Secret, from either TLS mode
fills the wildcard-talos-lab-example-io-tls Secret of the :443 listener
kubectl -n envoy-gateway-system get secret wildcard-…-tls
Name resolution for *.talos.lab.example.io → 192.168.56.200
routes match by hostname
dig +short argo.talos.lab.example.io
The TLS Secret comes from ../self-signed/ when
SELF_SIGNED=true (the default — a local CA, no domain and no token needed) or from
../cert-manager/ + a Cloudflare token when SELF_SIGNED=false.
The Gateway is the same either way: only the annotation differs. Likewise, resolution can be
an /etc/hosts line (self-signed) or a public DNS-only record (ACME).
HTTP (:80) works with neither TLS mode nor DNS: curl http://192.168.56.200/....
The controller is installed by the platform, step [2/4]:
./_k8s/platform-up.sh
OCI chart oci://docker.io/envoyproxy/gateway-helm1.8.3, pinned in ../platform-up.sh
(ENVOY_GW_VERSION, overridable). The chart also installs the standard Gateway API CRDs —
which cert-manager depends on (config.enableGatewayAPI=true). The script then applies
Envoy-Proxy.yml and waits for the LoadBalancer IP (30 × 5 s).
Manual equivalent (if you only want to install this component)
configures the Envoy infrastructure: type: LoadBalancer Service with loadBalancerClass: io.cilium/l2-announcer → the IP comes from the Cilium pool; and envoyDeployment.replicas: 2 for the data plane
GatewayClassenvoy
class managed by gateway.envoyproxy.io/gatewayclass-controller, pointing at the EnvoyProxy above
Gatewaymain-gateway (ns envoy-gateway-system)
the entry point: http:80 and https:443 listeners, allowedRoutes.namespaces.from: All
It is the EnvoyProxy's Service that triggers the Cilium L2 announcement → hence the .200 VIP.
Do not confuse the two Deployments in envoy-gateway-system:
Deployment
Role
Losing it
envoy-gateway
the controller: watches Gateways/HTTPRoutes and configures the proxies
no traffic impact, config just stops being reconciled
envoy-…-main-gateway-…
the data plane: the pods that actually carry the traffic
every UI in the lab is down
That second one is the single path for every UI (one LoadBalancer IP, .200), so
Envoy-Proxy.yml pins it at replicas: 2: the Service keeps a ready endpoint while a pod
is being rescheduled, a node reboots, or ../chaos-kube/ draws it
in its hourly lottery. Both pods share the same IP — nothing to change in DNS or in any
HTTPRoute.
ℹ️ Nothing pins the two pods to different nodes: the scheduler spreads them on its own,
but that is not a guarantee. For a hard guarantee, add a podAntiAffinity under
envoyDeployment.pod.affinity — with a required rule, keep it under the number of workers
or the surplus pods stay Pending.
The two listeners (already wired, nothing to add)#
The cert-manager.io/cluster-issuer annotation on the Gateway is enough for cert-manager to
create the Certificate, solve the DNS-01 challenge and fill the Secret. The versioned
manifest carries letsencrypt-staging; platform-up.sh rewrites it from LAB_ACME_ISSUER
(staging by default, prod on demand — mind the 5 certificates/week production cap). The
mechanism is detailed in ../cert-manager/README.md.
ℹ️ With the default SELF_SIGNED=true, platform-up.sh strips that annotation entirely
and fills the very same Secret with an openssl-signed wildcard
(../self-signed/). The certificateRefs above do not change —
which is exactly why no addon has to care which TLS mode the lab runs.
This is the only work left for a new component: an HTTPRoute targeting the TLS listener.
spec:parentRefs:-name:main-gatewaynamespace:envoy-gateway-systemsectionName:https# targets the :443 listener (without it, BOTH listeners)hostnames:-my-app.talos.lab.example.io# must match the wildcard *.talos.lab.example.iorules:-backendRefs:-name:my-appport:80
The route can live in its own namespace (the Gateway accepts from: All); the backend, on
the other hand, must be in the same namespace as the route — otherwise you need a
ReferenceGrant.
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP = 192.168.56.200 (otherwise → ../cilium/)
kubectlgetgateway-nenvoy-gateway-system# main-gateway, PROGRAMMED=True, ADDRESS=.200
kubectlgethttproute-A# every route in the lab# listeners + number of routes attached to each:
kubectl-nenvoy-gateway-systemgetgatewaymain-gateway\-ojsonpath='{range .status.listeners[*]}{.name}{" attached="}{.attachedRoutes}{"\n"}{end}'# the cert served for a hostname of the wildcard:echo|openssls_client-connect192.168.56.200:443-servernamedemo.talos.lab.example.io2>/dev/null\|opensslx509-noout-subject-issuer
Two apps and their HTTPRoutes, using path-based routing:
App
Route
Backend
hello-nginx (nginxdemos/nginx-hello:plain-text)
/hello → rewritten to /
hello-nginx:80
echo-app (ealen/echo-server:latest)
/echo → rewritten to /
echo-app:80
kubectlapply-f_k8s/envoy-gateway/GW-Example.yml# namespace `default`
curl-sShttp://192.168.56.200/hello
curl-sShttp://192.168.56.200/echo
kubectldelete-f_k8s/envoy-gateway/GW-Example.yml# remove after the demo
ℹ️ These routes have neither hostnames nor sectionName: they therefore attach to both
listeners. Verified consequence: /hello also answers over HTTPS, under any subdomain of the
wildcard (https://foo.talos.lab.example.io/hello → 200). On the other hand
https://hello.talos.lab.example.io/ returns 404: the match is on the path, not on the
hostname.
Empty ADDRESS / <pending> → the problem is on the ../cilium/
side (missing pool or inactive L2 announcement), not here.
404 on a route → path/hostname matching nothing, sectionName missing or wrong, or a
hostname outside the wildcard (app.talos.lab.example.io ✔, app.lab.example.io ✘ — the
wildcard covers one level only).
Not every UI exposed behind this Gateway has authentication. The Longhorn UI
(../longhorn/httproute.yaml) has none; neither does the Policy Reporter UI (nothing is
configured in ../kyverno/policy-reporter-values.yaml). Published on the VIP, they are
reachable by anyone who reaches .200 — so by every authorized Tailscale peer. To protect
them: an Envoy Gateway SecurityPolicy (Basic Auth / OIDC) targeting the route. Vault and
Argo CD do have their own authentication.
GW-Example.yml violates the repo's own policies: ealen/echo-server:latest is rejected by
disallow-latest-tag (../kyverno/), and
nginxdemos/nginx-hello:plain-text is a floating tag (it passes the policy but pins no
version). Both apps also trigger the Talos restricted PodSecurity warnings
(allowPrivilegeEscalation, capabilities, runAsNonRoot, seccompProfile): those really are
warnings, since the Talos enforce level is baseline. Ideal demo material for "here is what
a policy catches".
The demo apps land in default (no namespace in the manifest): delete them after the demo
so they do not pollute the Kyverno/Trivy reports.
A competing Gateway overwrites this one: ../cert-manager/04-gateway-https-example.yaml
redefines main-gateway with the same name/namespace. Do not apply it (see its README).
🚪envoy-gateway/ — le point d'entrée HTTP(S) du cluster
Un seul VIP, deux écouteurs, N applications.Envoy Gateway
(implémentation de la Gateway API) déploie un Envoy dont le Service LoadBalancer récupère
le VIP 192.168.56.200 du pool Cilium. Le Gatewaymain-gateway y expose :80et:443
(TLS wildcard *.talos.lab.example.io), et chaque addon s'y branche avec une HTTPRoute.
🌐 talos.lab.example.io est le domaine NEUTRE du dépôt (public) : platform-up.sh le
remplace par LAB_DOMAIN (lab.env) — hostname de l'écouteur https et nom du Secret TLS.
Cf. ../LISEZ-MOI.md.
Mutualiser l'exposition : une IP, un certificat, un point de configuration pour toutes les
UI du lab (Argo CD, Vault, Longhorn, Grafana, Policy Reporter, WordPress…).
Faire la Gateway API en vrai : GatewayClass → Gateway → HTTPRoute, avec
rattachement inter-namespace, filtres et routage par chemin ou par hostname.
Terminer le TLS au bord du cluster : les backends parlent HTTP en clair.
⚠️ Ne pas confondre avec l'Envoy embarqué dans Cilium (désactivé ici :
envoy.enabled=false, cf. ../cilium/LISEZ-MOI.md). Ici Envoy est
piloté par le contrôleur Envoy Gateway, un composant à part entière.
c'est lui qui donne l'IP .200 au Service du Gateway
kubectl get ciliumloadbalancerippool
Un Secret TLS wildcard, venu de l'un ou l'autre mode TLS
remplit le Secret wildcard-talos-lab-example-io-tls de l'écouteur :443
kubectl -n envoy-gateway-system get secret wildcard-…-tls
Résolution de *.talos.lab.example.io → 192.168.56.200
les routes matchent par hostname
dig +short argo.talos.lab.example.io
Le Secret TLS vient de ../self-signed/ quand
SELF_SIGNED=true (le défaut — une AC locale, sans domaine ni token) ou de
../cert-manager/ + un token Cloudflare quand
SELF_SIGNED=false. Le Gateway est le même dans les deux cas : seule l'annotation change. De
même, la résolution peut être une ligne /etc/hosts (auto-signé) ou un enregistrement public
DNS-only (ACME).
Le HTTP (:80) fonctionne sans aucun des deux modes TLS et sans DNS :
curl http://192.168.56.200/....
Le contrôleur est installé par la plateforme, étape [2/4] :
./_k8s/platform-up.sh
Chart OCI oci://docker.io/envoyproxy/gateway-helm1.8.3, épinglé dans
../platform-up.sh (ENVOY_GW_VERSION, surchargeable). Le chart installe aussi les CRD
Gateway API standard — dont dépend cert-manager (config.enableGatewayAPI=true). Le script
applique ensuite Envoy-Proxy.yml, puis attend l'IP LoadBalancer (30 × 5 s).
Équivalent manuel (si tu veux ne poser que cette brique)
paramètre l'infra Envoy : Service type: LoadBalancer avec loadBalancerClass: io.cilium/l2-announcer → l'IP vient du pool Cilium ; et envoyDeployment.replicas: 2 pour le plan de données
GatewayClassenvoy
classe gérée par gateway.envoyproxy.io/gatewayclass-controller, pointant l'EnvoyProxy ci-dessus
Gatewaymain-gateway (ns envoy-gateway-system)
le point d'entrée : écouteurs http:80 et https:443, allowedRoutes.namespaces.from: All
C'est le Service de l'EnvoyProxy qui déclenche l'annonce L2 Cilium → d'où le VIP .200.
Ne pas confondre les deux Deployments de envoy-gateway-system :
Deployment
Rôle
Le perdre
envoy-gateway
le contrôleur : observe Gateways/HTTPRoutes et configure les proxys
aucun impact trafic, la config cesse juste d'être réconciliée
envoy-…-main-gateway-…
le plan de données : les pods qui portent réellement le trafic
toutes les UI du lab tombent
Ce second Deployment est le passage unique de toutes les UI (une seule IP LoadBalancer, .200),
d'où le replicas: 2 figé dans Envoy-Proxy.yml : le Service garde un endpoint prêt pendant
qu'un pod est reprogrammé, qu'un node redémarre, ou que ../chaos-kube/
le tire dans sa loterie horaire. Les deux pods partagent la même IP — rien à changer côté DNS ni
dans les HTTPRoute.
ℹ️ Rien n'épingle les deux pods sur des nodes différents : le scheduler les répartit de
lui-même, mais ce n'est pas une garantie. Pour une garantie dure, ajouter un podAntiAffinity
sous envoyDeployment.pod.affinity — avec une règle required, rester sous le nombre de
workers sinon les pods en trop restent Pending.
L'annotation cert-manager.io/cluster-issuer sur le Gateway suffit à ce que cert-manager crée
le Certificate, résolve le challenge DNS-01 et remplisse le Secret. Le manifeste versionné
porte letsencrypt-staging ; platform-up.sh la réécrit depuis LAB_ACME_ISSUER (staging
par défaut, prod sur demande — attention au plafond de 5 certificats/semaine en
production). Le mécanisme est détaillé dans
../cert-manager/LISEZ-MOI.md.
ℹ️ Avec le défaut SELF_SIGNED=true, platform-up.sh retire purement et simplement cette
annotation et remplit exactement le même Secret avec un wildcard signé par openssl
(../self-signed/). Les certificateRefs ci-dessus ne
changent pas — c'est précisément pour ça qu'aucun addon n'a à savoir dans quel mode TLS
tourne le lab.
C'est le seul travail restant pour un nouvel addon : une HTTPRoute qui cible l'écouteur TLS.
spec:parentRefs:-name:main-gatewaynamespace:envoy-gateway-systemsectionName:https# cible l'écouteur :443 (sans ça, les DEUX écouteurs)hostnames:-mon-app.talos.lab.example.io# doit matcher le wildcard *.talos.lab.example.iorules:-backendRefs:-name:mon-appport:80
La route peut vivre dans son namespace (le Gateway accepte from: All) ; le backend doit,
lui, être dans le même namespace que la route — sinon il faut un ReferenceGrant.
kubectl-nenvoy-gateway-systemgetsvc# EXTERNAL-IP = 192.168.56.200 (sinon → ../cilium/)
kubectlgetgateway-nenvoy-gateway-system# main-gateway, PROGRAMMED=True, ADDRESS=.200
kubectlgethttproute-A# toutes les routes du lab# écouteurs + nombre de routes attachées à chacun :
kubectl-nenvoy-gateway-systemgetgatewaymain-gateway\-ojsonpath='{range .status.listeners[*]}{.name}{" attached="}{.attachedRoutes}{"\n"}{end}'# le cert servi pour un hostname du wildcard :echo|openssls_client-connect192.168.56.200:443-servernamedemo.talos.lab.example.io2>/dev/null\|opensslx509-noout-subject-issuer
Deux apps + leurs HTTPRoute, en routage par chemin :
App
Route
Backend
hello-nginx (nginxdemos/nginx-hello:plain-text)
/hello → réécrit /
hello-nginx:80
echo-app (ealen/echo-server:latest)
/echo → réécrit /
echo-app:80
kubectlapply-f_k8s/envoy-gateway/GW-Example.yml# namespace `default`
curl-sShttp://192.168.56.200/hello
curl-sShttp://192.168.56.200/echo
kubectldelete-f_k8s/envoy-gateway/GW-Example.yml# à retirer après la démo
ℹ️ Ces routes n'ont ni hostnames ni sectionName : elles s'attachent donc aux deux
écouteurs. Conséquence vérifiée : /hello répond aussi en HTTPS, sous n'importe quel
sous-domaine du wildcard (https://foo.talos.lab.example.io/hello → 200). En revanche
https://hello.talos.lab.example.io/ renvoie 404 : le match porte sur le chemin, pas sur
le nom d'hôte.
ADDRESS vide / <pending> → le problème est côté ../cilium/
(pool absent ou annonce L2 inactive), pas ici.
404 sur une route → chemin/hostname qui ne matche rien, sectionName absent ou faux, ou
hostname hors du wildcard (app.talos.lab.example.io ✔, app.lab.example.io ✘ — le wildcard ne
couvre qu'un niveau).
Les UI exposées derrière ce Gateway n'ont pas toutes d'authentification. L'UI Longhorn
(../longhorn/httproute.yaml) n'en a aucune ; l'UI Policy Reporter non plus (rien n'est
configuré dans ../kyverno/policy-reporter-values.yaml). Publiées sur le VIP, elles sont
accessibles à quiconque atteint .200 — donc à tout peer Tailscale autorisé. Pour les
protéger : SecurityPolicy Envoy Gateway (Basic Auth / OIDC) ciblant la route. Vault et
Argo CD, eux, ont leur propre authentification.
GW-Example.yml viole les policies du dépôt lui-même : ealen/echo-server:latest est
refusé par disallow-latest-tag (../kyverno/), et
nginxdemos/nginx-hello:plain-text est un tag flottant (il passe la policy mais ne fixe
aucune version). Les deux apps déclenchent aussi les avertissements PodSecurity restricted
de Talos (allowPrivilegeEscalation, capabilities, runAsNonRoot, seccompProfile) : ce
sont bien des warnings, l'enforce Talos étant à baseline. Support de démo idéal pour
« voici ce qu'une policy attrape ».
Les apps de démo atterrissent dans default (aucun namespace dans le manifeste) : à
supprimer après la démo pour ne pas polluer les rapports Kyverno/Trivy.
Un Gateway concurrent écrase celui-ci : ../cert-manager/04-gateway-https-example.yaml
redéfinit main-gateway avec les mêmes name/namespace. Ne pas l'appliquer (cf. son README).
🔏self-signed/ — wildcard TLS without cert-manager (openssl)
HTTPS on every lab UI with no domain, no Cloudflare token and no Internet.
A local CA generated once on the host signs a *.<LAB_DOMAIN> wildcard, which lands in
exactly the Secret the Envoy :443 listener already expects. cert-manager is not
installed at all.
This is the default TLS mode of the lab (SELF_SIGNED=true in lab.env). It exists
because the other path has real prerequisites: the ACME route
(../cert-manager/) needs a domain you actually own, a
Cloudflare token, and it spends Let's Encrypt quota on every rebuild. For a
throwaway lab on a host-only network, that is a lot of setup for a certificate nobody
outside your machine will ever see.
The trade-off is the only one that matters here: the certificate is not publicly
trusted. Browsers warn until you import the CA once — see 🌐 Access.
_out/self-signed/ca.key + ca.crt local CA, 10 years, generated ONCE and reused
│ signs
▼
_out/self-signed/tls.key + tls.crt leaf, 825 days
│ SAN: DNS:*.<LAB_DOMAIN>, DNS:<LAB_DOMAIN> · extendedKeyUsage: serverAuth
▼
Secret wildcard-<LAB_DOMAIN with dashes>-tls (ns envoy-gateway-system, type kubernetes.io/tls)
│ tls.crt = leaf + CA (full chain)
▼
served by Envoy on :443 — the same Secret name cert-manager would have filled
Because the Secret name is identical on both paths, the Gateway manifest does not
change between modes: platform-up.sh only strips the
cert-manager.io/cluster-issuer annotation from main-gateway when SELF_SIGNED=true,
so nothing ever tries to take the Secret over.
_out/ is gitignored — the CA private key can never end up in a commit. It also sits
on the host, not in etcd, so it survives vagrant destroy: you import the CA into
your trust store once and every future rebuild of the lab is trusted immediately. That is
the opposite of the ACME path, where each rebuild burns a fresh certificate.
_out/self-signed/tls.crt is missing — e.g. after you wiped _out/;
it expires in less than 30 days (RENEW_DAYS);
LAB_DOMAIN changed, so the SAN no longer covers the lab.
Overridable knobs: CA_DAYS (3650), CERT_DAYS (825), RENEW_DAYS (30). 825 days is the
ceiling browsers accept for a server certificate — do not raise CERT_DAYS above it.
kubectl-nenvoy-gateway-systemgetsecretwildcard-<domain-in-dashes>-tls# type kubernetes.io/tls
kubectl-nenvoy-gateway-systemgetgatewaymain-gateway# PROGRAMMED=True
opensslx509-in_out/self-signed/tls.crt-noout-subject-issuer-dates-extsubjectAltName
# Which certificate does Envoy actually serve?echo|openssls_client-connect192.168.56.200:443-servernamedemo.<LAB_DOMAIN>2>/dev/null\|opensslx509-noout-subject-issuer
# expected: subject=CN=*.<LAB_DOMAIN>, issuer=CN=Vagrant-Talos self-signed CA# End-to-end, validating against the local CA (needs a hostname carrying an HTTPRoute):
curl-sS-o/dev/null-w'%{http_code} verify=%{ssl_verify_result}\n'\--cacert_out/self-signed/ca.crt\--resolveargo.<LAB_DOMAIN>:443:192.168.56.200https://argo.<LAB_DOMAIN>/
# expected: 200 verify=0
192.168.56.200 is the Gateway's EXTERNAL-IP (LB_POOL_START). If you do own a DNS
zone, a wildcard A record is nicer — see
../README.md.
2. Trust the CA — once, and it holds across every rebuild:
# Linux (Debian/Ubuntu), system store
sudocp_out/self-signed/ca.crt/usr/local/share/ca-certificates/vagrant-talos-lab.crt
sudoupdate-ca-certificates
# macOS
sudosecurityadd-trusted-cert-d-rtrustRoot\-k/Library/Keychains/System.keychain_out/self-signed/ca.crt
Firefox has its own store and ignores the system one: Settings → Privacy & Security →
Certificates → View Certificates → Authorities → Import → _out/self-signed/ca.crt.
Skipping this step is fine too — you just click through the browser warning on each UI.
Switching false → true on a live cluster leaves cert-manager behind, and its
Certificate object keeps reconciling the Secret. Re-running platform-up.sh removes
the Gateway annotation, which makes cert-manager drop the Certificate — but if the
object survives, delete it explicitly, otherwise it overwrites the self-signed Secret:
kubectl -n envoy-gateway-system delete certificate <wildcard>-tls.
Switching true → false does the reverse: delete the self-signed Secret so
cert-manager issues a fresh one
(kubectl -n envoy-gateway-system delete secret <wildcard>-tls).
Deleting _out/ throws the CA away. A new CA means re-importing it into every trust
store. _out/ is gitignored and never backed up — if you care about the CA, copy
ca.crtandca.key somewhere safe before a cleanup.
The CA private key is a real secret. Anyone holding _out/self-signed/ca.key can
forge a certificate for any domain that your trust store will accept, not just the
lab's. That is the price of importing a CA rather than a single certificate — keep the
file at 600 (the script sets it) and do not copy it around.
No in-cluster renewal. Nothing watches the expiry: after 825 days, or after 795 with
the 30-day margin, you re-run selfsigned-up.sh. For a lab that is rebuilt regularly,
this never comes up.
A single wildcard level: *.<LAB_DOMAIN> covers argo.<LAB_DOMAIN>, not
a.b.<LAB_DOMAIN>. Same constraint as the ACME path.
curl without --cacert fails with unable to get local issuer certificate. That
is the certificate doing its job, not a bug — pass --cacert _out/self-signed/ca.crt,
or import the CA.
🔏self-signed/ — wildcard TLS sans cert-manager (openssl)
Du HTTPS sur toutes les UI du lab sans domaine, sans token Cloudflare et sans Internet.
Une AC locale, générée une fois sur l'hôte, signe un wildcard *.<LAB_DOMAIN> qui atterrit
exactement dans le Secret qu'attend déjà l'écouteur :443 d'Envoy. cert-manager n'est pas
installé du tout.
C'est le mode TLS par défaut du lab (SELF_SIGNED=true dans lab.env). Il existe parce
que l'autre chemin a de vrais prérequis : la voie ACME
(../cert-manager/) exige un domaine qui t'appartient
vraiment, un token Cloudflare, et elle consomme du quota Let's Encrypt à chaque
rebuild. Pour un lab jetable sur un réseau host-only, ça fait beaucoup de mise en place pour
un certificat que personne, hors de ta machine, ne verra jamais.
Le compromis est le seul qui compte ici : le certificat n'est pas publiquement trusté.
Le navigateur avertit tant que tu n'as pas importé l'AC une fois — voir 🌐 Accès.
Aucune zone DNS, aucun token d'API, aucun port entrant. Le domaine n'a pas besoin
d'exister publiquement : il doit seulement résoudre sur la machine depuis laquelle tu
navigues.
_out/self-signed/ca.key + ca.crt AC locale, 10 ans, générée UNE FOIS puis réutilisée
│ signe
▼
_out/self-signed/tls.key + tls.crt feuille, 825 jours
│ SAN : DNS:*.<LAB_DOMAIN>, DNS:<LAB_DOMAIN> · extendedKeyUsage : serverAuth
▼
Secret wildcard-<LAB_DOMAIN en tirets>-tls (ns envoy-gateway-system, type kubernetes.io/tls)
│ tls.crt = feuille + AC (chaîne complète)
▼
servi par Envoy sur :443 — le même nom de Secret que cert-manager aurait rempli
Comme le nom du Secret est identique sur les deux chemins, le manifeste de la Gateway ne
change pas d'un mode à l'autre : platform-up.sh se contente de retirer l'annotation
cert-manager.io/cluster-issuer de main-gateway quand SELF_SIGNED=true, pour que rien ne
vienne jamais reprendre la main sur le Secret.
_out/ est gitignoré : la clé privée de l'AC ne peut pas se retrouver dans un commit.
Il vit aussi sur l'hôte, pas dans etcd, donc il survit à vagrant destroy : tu
importes l'AC une fois dans ton magasin de confiance et tous les rebuilds suivants sont
trustés d'emblée. C'est l'inverse du chemin ACME, où chaque rebuild brûle un certificat neuf.
Le script refabrique la feuille (jamais l'AC) quand :
_out/self-signed/tls.crt manque — p.ex. après avoir effacé _out/ ;
il expire dans moins de 30 jours (RENEW_DAYS) ;
LAB_DOMAIN a changé, donc le SAN ne couvre plus le lab.
Réglages surchargeables : CA_DAYS (3650), CERT_DAYS (825), RENEW_DAYS (30). 825 jours
est le plafond qu'acceptent les navigateurs pour un certificat serveur — ne monte pas
CERT_DAYS au-delà.
kubectl-nenvoy-gateway-systemgetsecretwildcard-<domaine-en-tirets>-tls# type kubernetes.io/tls
kubectl-nenvoy-gateway-systemgetgatewaymain-gateway# PROGRAMMED=True
opensslx509-in_out/self-signed/tls.crt-noout-subject-issuer-dates-extsubjectAltName
# Quel certificat Envoy sert-il vraiment ?echo|openssls_client-connect192.168.56.200:443-servernamedemo.<LAB_DOMAIN>2>/dev/null\|opensslx509-noout-subject-issuer
# attendu : subject=CN=*.<LAB_DOMAIN>, issuer=CN=Vagrant-Talos self-signed CA# De bout en bout, en validant contre l'AC locale (il faut un hostname portant une HTTPRoute) :
curl-sS-o/dev/null-w'%{http_code} verify=%{ssl_verify_result}\n'\--cacert_out/self-signed/ca.crt\--resolveargo.<LAB_DOMAIN>:443:192.168.56.200https://argo.<LAB_DOMAIN>/
# attendu : 200 verify=0
192.168.56.200 est l'EXTERNAL-IP de la Gateway (LB_POOL_START). Si tu possèdes une zone
DNS, un enregistrement A wildcard est plus confortable — voir
../LISEZ-MOI.md.
2. Faire confiance à l'AC — une fois, et ça tient pour tous les rebuilds :
# Linux (Debian/Ubuntu), magasin système
sudocp_out/self-signed/ca.crt/usr/local/share/ca-certificates/vagrant-talos-lab.crt
sudoupdate-ca-certificates
# macOS
sudosecurityadd-trusted-cert-d-rtrustRoot\-k/Library/Keychains/System.keychain_out/self-signed/ca.crt
Firefox a son propre magasin et ignore celui du système : Paramètres → Vie privée et
sécurité → Certificats → Afficher les certificats → Autorités → Importer →
_out/self-signed/ca.crt.
Sauter cette étape est acceptable aussi : tu cliques simplement à travers l'avertissement du
navigateur sur chaque UI.
Passer de false à true sur un cluster vivant laisse cert-manager derrière, et son
objet Certificate continue de réconcilier le Secret. Relancer platform-up.sh retire
l'annotation de la Gateway, ce qui fait abandonner le Certificate à cert-manager — mais
si l'objet survit, supprime-le explicitement, sinon il écrase le Secret auto-signé :
kubectl -n envoy-gateway-system delete certificate <wildcard>-tls.
Passer de true à false demande l'inverse : supprimer le Secret auto-signé pour que
cert-manager en émette un neuf
(kubectl -n envoy-gateway-system delete secret <wildcard>-tls).
Effacer _out/ jette l'AC. Une nouvelle AC, c'est un ré-import dans chaque magasin de
confiance. _out/ est gitignoré et n'est jamais sauvegardé — si l'AC compte pour toi, copie
ca.crtetca.key ailleurs avant un nettoyage.
La clé privée de l'AC est un vrai secret. Qui détient _out/self-signed/ca.key peut
forger un certificat pour n'importe quel domaine que ton magasin de confiance
acceptera, pas seulement ceux du lab. C'est le prix d'importer une AC plutôt qu'un
certificat isolé : garde le fichier en 600 (le script s'en charge) et ne le promène pas.
Aucun renouvellement dans le cluster. Rien ne surveille l'expiration : au bout de
825 jours — 795 avec la marge de 30 jours — tu relances selfsigned-up.sh. Pour un lab
reconstruit régulièrement, le cas ne se présente jamais.
Un seul niveau de wildcard : *.<LAB_DOMAIN> couvre argo.<LAB_DOMAIN>, pas
a.b.<LAB_DOMAIN>. Même contrainte que sur le chemin ACME.
curl sans --cacert échoue avec unable to get local issuer certificate. C'est le
certificat qui fait son travail, pas un bug — passe --cacert _out/self-signed/ca.crt, ou
importe l'AC.
A public *.talos.lab.example.io certificate, issued and renewed with zero effort.
cert-manager watches main-gateway, reads an annotation on it, creates the Certificate,
proves to Let's Encrypt that you control the domain through a DNS TXT record at Cloudflare,
then fills the Secret that Envoy's :443 listener serves. No inbound port, no hand-written
Certificate.
⚠️ This is the opt-in TLS mode.platform-up.sh installs cert-manager only when
SELF_SIGNED=false in lab.env. The default (SELF_SIGNED=true) signs the same wildcard
locally with openssl and installs none of this — see
../self-signed/, which also compares the two modes side by
side. Everything below assumes you set SELF_SIGNED=false.
Every lab UI (argo., vault., longhorn., grafana., kyverno., wordpress.…) is served
over HTTPS trusted by browsers behind a private IP, with no security exception to click and
no home-made CA to distribute.
DNS-01: Let's Encrypt validates the domain through a
_acme-challenge.talos.lab.example.io TXT record, written by cert-manager with the Cloudflare
token. No inbound connection required → it works behind a host-only network + Tailscale,
where HTTP-01 would fail.
Wildcard: only DNS-01 can issue *.talos.lab.example.io (HTTP-01 cannot).
Let's Encrypt rather than Cloudflare Origin CA: since DNS is in DNS-only mode (grey
cloud), TLS is terminated by Envoy, not by the Cloudflare edge. So it is the browser that
validates Envoy's certificate → it must be publicly trusted. An Origin CA cert (trusted
only by the Cloudflare edge) would be rejected.
Cloudflare API token (Zone/DNS/Edit + Zone/Zone/Read, scoped to example.io)
lets cert-manager write the TXT record
kubectl -n cert-manager get secret cloudflare-api-token
The token goes into lab.env (CLOUDFLARE_API_TOKEN=…, a gitignored file): that is where
platform-up.sh picks it up to create the Secret.
🌐 Neutral domain by default (the repo is public): the manifests carry
talos.lab.example.io and the example.io zone. platform-up.sh substitutes on the fly, from
lab.env: LAB_DOMAIN (wildcard hostname), LAB_DNS_ZONE (the solver's dnsZones — default:
the last 2 labels of LAB_DOMAIN) and LAB_ACME_EMAIL (default admin@<zone>). The TLS
Certificate/Secret follows the domain: wildcard-<LAB_DOMAIN with dashes>-tls. Without the
substitution the solver would never match your zone and the certificate would stay pending. See
../README.md.
cert-manager is installed by the platform, step [4/4], provided SELF_SIGNED=false:
echo'SELF_SIGNED=false'>>lab.env# otherwise step [4/4] goes the self-signed route
./_k8s/platform-up.sh
Chart jetstack/cert-managerv1.20.2, pinned in ../platform-up.sh
(CERT_MANAGER_VERSION). The script:
installs the chart with crds.enabled=true and config.enableGatewayAPI=true (Gateway API
integration, no longer behind a feature flag since cert-manager 1.15);
creates the cloudflare-api-token Secret from lab.env (it warns and continues if the token
is empty — the certificate then stays pending);
applies 02-clusterissuer-staging.yaml and 03-clusterissuer-prod.yaml;
waits for Ready=True on the wildcard-talos-lab-example-io-tlsCertificate — a name
derived from LAB_DOMAIN (~1-2 min, 24 × 10 s).
Manual equivalent (installing only this component)
helmrepoaddjetstackhttps://charts.jetstack.io&&helmrepoupdate
# --version: keep the one from platform-up.sh (CERT_MANAGER_VERSION)
helmupgrade--installcert-managerjetstack/cert-manager\--namespacecert-manager--create-namespace\--versionv1.20.2\--setcrds.enabled=true\--setconfig.apiVersion="controller.config.cert-manager.io/v1alpha1"\--setconfig.kind="ControllerConfiguration"\--setconfig.enableGatewayAPI=true
kubectl-ncert-managerrolloutstatusdeploy/cert-manager
kubectlcreatesecretgenericcloudflare-api-token-ncert-manager\--from-literal=api-token='<YOUR_TOKEN>'
kubectlapply-f_k8s/cert-manager/02-clusterissuer-staging.yaml\-f_k8s/cert-manager/03-clusterissuer-prod.yaml
Gateway main-gateway
├─ annotation cert-manager.io/cluster-issuer: letsencrypt-staging (LAB_ACME_ISSUER)
└─ listener https (hostname *.talos.lab.example.io, certificateRefs: wildcard-talos-lab-example-io-tls)
│
▼ cert-manager (config.enableGatewayAPI=true) watches the Gateway
Certificate wildcard-talos-lab-example-io-tls (dnsNames derived from the listener `hostname`)
│ Order ──► Challenge dns-01 ──► TXT _acme-challenge.talos.lab.example.io (Cloudflare API)
▼
Secret wildcard-talos-lab-example-io-tls (ns envoy-gateway-system) ──► served by Envoy on :443
The Certificateand the Secret are born in the Gateway's namespace
(envoy-gateway-system), not in cert-manager. Renewal is automatic (at ~2/3 of the lifetime).
💡 Without the Gateway API integration, you get the same result by hand: write a
Certificate (dnsNames: ["*.talos.lab.example.io"], issuerRef: letsencrypt-staging,
secretName: wildcard-talos-lab-example-io-tls) and let the listener reference it. Same
outcome, you just create the object instead of cert-manager.
Let's Encrypt prodClusterIssuer (trusted cert) — the one referenced by the Gateway
04-gateway-https-example.yaml
historical illustration, do NOT apply (see ⚠️ Pitfalls)
⚠️ 04-gateway-https-example.yaml must not be applied any more. It contains a full
main-gatewayGateway (same name/namespace): a kubectl apply would replace the
Gateway in place. The merge is already done in ../envoy-gateway/Envoy-Proxy.yml
(https:443 listener + wildcard hostname + certificateRefs + cluster-issuer annotation),
and ../platform-up.sh only applies 02- and 03-. Keep the file as reading material: it
shows the "HTTPS + cert-manager" part of the Gateway in isolation.
kubectlgetclusterissuer# both issuers, READY=True
kubectl-nenvoy-gateway-systemgetcertificate# wildcard-…-tls, READY=True
kubectl-nenvoy-gateway-systemdescribecertificatewildcard-talos-lab-example-io-tls
# events: Order → Challenge → issued
kubectlgetchallenges-A# empty once validated# Which certificate does Envoy serve? (no HTTPRoute needed: we only test TLS)echo|openssls_client-connect192.168.56.200:443-servernamedemo.talos.lab.example.io2>/dev/null\|opensslx509-noout-subject-issuer-dates
# expected: subject=CN=*.talos.lab.example.io, issuer=Let's Encrypt (and not "STAGING")
End-to-end HTTPS test: you need a hostname that carries an HTTPRoute (the demo routes in
../envoy-gateway/GW-Example.yml match by path, not by hostname). For example, once the Argo CD
component is installed:
Challenge stuck in pending → Cloudflare token (permissions or zone), or slow TXT
propagation. kubectl describe challenge <name> gives the exact error from the Cloudflare API.
cloudflare-api-token Secret missing → CLOUDFLARE_API_TOKEN was empty in lab.env when
platform-up.sh ran (the script reports it without failing). Create the Secret, then
kubectl -n envoy-gateway-system delete challenge --all to retry (the Orders/Challenges
live in the Certificate's namespace, so in the Gateway's).
Certificate never created despite the annotation → cert-manager is not running with
config.enableGatewayAPI=true, or it started before the Gateway API CRDs:
kubectl -n cert-manager rollout restart deploy/cert-manager.
Browser refusing the certificate → you are on letsencrypt-staging, which is the
default. Set LAB_ACME_ISSUER=prod in lab.env, re-run platform-up.sh, then delete the
Secret to force a reissue:
kubectl -n envoy-gateway-system delete secret wildcard-<domain-in-dashes>-tls.
429 rateLimited in prod → the 5 certificates per week per identifier set cap is
reached. Nothing to fix, nothing to retry: the message carries the retry after timestamp and
the window is 168 h sliding. Note that every vagrant destroy burns a slot, because the
wildcard only lives in etcd. Go back to LAB_ACME_ISSUER=staging while you wait, or back the
Secret up before destroying (see ../../README.md §5).
SELF_SIGNED=true (the default) skips this whole page. If kubectl get clusterissuer
answers no resources found and the Gateway carries no cert-manager.io/cluster-issuer
annotation, nothing is broken — you are simply on the self-signed path. Set
SELF_SIGNED=false in lab.env and re-run platform-up.sh, then delete the leftover
self-signed Secret so cert-manager issues its own:
kubectl -n envoy-gateway-system delete secret <wildcard>-tls.
Do not apply 04-gateway-https-example.yaml (see the callout above).
A single wildcard level: *.talos.lab.example.io covers argo.talos.lab.example.io, not
a.b.talos.lab.example.io. A route with a hostname that is not covered will not attach to the
listener.
The committed ACME e-mail is neutral (admin@example.io): platform-up.sh replaces it with
LAB_ACME_EMAIL (default admin@<LAB_DNS_ZONE>). With a direct kubectl apply -f, you apply
the example address — and Let's Encrypt rejects some reserved domains.
DNS-only is mandatory on the Cloudflare side: in "orange proxy" mode the edge would try to
reach 192.168.56.200 and access would break (the DNS-01 challenge itself would still work).
Un certificat *.talos.lab.example.io public, émis et renouvelé sans rien faire. cert-manager
surveille main-gateway, y lit une annotation, crée le Certificate, prouve à Let's Encrypt
que tu contrôles le domaine via un TXT DNS chez Cloudflare, puis remplit le Secret que
l'écouteur :443 d'Envoy sert. Aucun port entrant, aucun Certificate écrit à la main.
⚠️ C'est le mode TLS optionnel.platform-up.sh n'installe cert-manager que si
SELF_SIGNED=false dans lab.env. Le défaut (SELF_SIGNED=true) signe le même wildcard
localement avec openssl et n'installe rien de tout ceci — voir
../self-signed/, qui compare aussi les deux modes point par
point. Tout ce qui suit suppose que tu as posé SELF_SIGNED=false.
Toutes les UI du lab (argo., vault., longhorn., grafana., kyverno., wordpress.…)
sont servies en HTTPS trusté par les navigateurs derrière une IP privée, sans exception de
sécurité à cliquer et sans CA maison à distribuer.
DNS-01 : Let's Encrypt vérifie le domaine via un TXT _acme-challenge.talos.lab.example.io,
posé par cert-manager avec le token Cloudflare. Aucune connexion entrante requise → ça
marche derrière un réseau host-only + Tailscale, là où HTTP-01 échouerait.
Wildcard : seul DNS-01 sait émettre *.talos.lab.example.io (HTTP-01 ne peut pas).
Let's Encrypt plutôt que Cloudflare Origin CA : comme le DNS est en DNS-only (nuage
gris), le TLS est terminé par Envoy, pas par l'edge Cloudflare. C'est donc le
navigateur qui valide le certificat d'Envoy → il doit être publiquement trusté. Un cert
Origin CA (trusté seulement par l'edge Cloudflare) serait rejeté.
Token API Cloudflare (Zone/DNS/Edit + Zone/Zone/Read, scopé example.io)
permet à cert-manager de poser le TXT
kubectl -n cert-manager get secret cloudflare-api-token
Le token se met dans lab.env (CLOUDFLARE_API_TOKEN=…, fichier gitignoré) : c'est là que
platform-up.sh va le chercher pour créer le Secret.
🌐 Domaine neutre par défaut (le dépôt est public) : les manifestes portent
talos.lab.example.io et la zone example.io. platform-up.sh substitue à la volée, depuis
lab.env : LAB_DOMAIN (hostname du wildcard), LAB_DNS_ZONE (le dnsZones du solveur —
défaut : les 2 derniers labels de LAB_DOMAIN) et LAB_ACME_EMAIL (défaut admin@<zone>).
Le Certificate/Secret TLS suit le domaine : wildcard-<LAB_DOMAIN avec des tirets>-tls.
Sans substitution, le solveur ne matcherait jamais ta zone et le certificat resterait en
attente. Cf. ../LISEZ-MOI.md.
cert-manager est installé par la plateforme, étape [4/4], à condition que
SELF_SIGNED=false :
echo'SELF_SIGNED=false'>>lab.env# sinon l'étape [4/4] part en auto-signé
./_k8s/platform-up.sh
Chart jetstack/cert-managerv1.20.2, épinglé dans ../platform-up.sh
(CERT_MANAGER_VERSION). Le script :
installe le chart avec crds.enabled=true et config.enableGatewayAPI=true (intégration
Gateway API, non gatée par un feature-flag depuis cert-manager 1.15) ;
crée le Secret cloudflare-api-token depuis lab.env (il avertit et continue si le token
est vide — le certificat restera alors en attente) ;
applique 02-clusterissuer-staging.yaml et 03-clusterissuer-prod.yaml ;
attend Ready=True sur le Certificatewildcard-talos-lab-example-io-tls — nom dérivé de
LAB_DOMAIN (~1-2 min, 24 × 10 s).
Équivalent manuel (poser uniquement cette brique)
helmrepoaddjetstackhttps://charts.jetstack.io&&helmrepoupdate
# --version : garder celle de platform-up.sh (CERT_MANAGER_VERSION)
helmupgrade--installcert-managerjetstack/cert-manager\--namespacecert-manager--create-namespace\--versionv1.20.2\--setcrds.enabled=true\--setconfig.apiVersion="controller.config.cert-manager.io/v1alpha1"\--setconfig.kind="ControllerConfiguration"\--setconfig.enableGatewayAPI=true
kubectl-ncert-managerrolloutstatusdeploy/cert-manager
kubectlcreatesecretgenericcloudflare-api-token-ncert-manager\--from-literal=api-token='<TON_TOKEN>'
kubectlapply-f_k8s/cert-manager/02-clusterissuer-staging.yaml\-f_k8s/cert-manager/03-clusterissuer-prod.yaml
Gateway main-gateway
├─ annotation cert-manager.io/cluster-issuer: letsencrypt-staging (LAB_ACME_ISSUER)
└─ listener https (hostname *.talos.lab.example.io, certificateRefs: wildcard-talos-lab-example-io-tls)
│
▼ cert-manager (config.enableGatewayAPI=true) observe le Gateway
Certificate wildcard-talos-lab-example-io-tls (dnsNames déduits du `hostname` de l'écouteur)
│ Order ──► Challenge dns-01 ──► TXT _acme-challenge.talos.lab.example.io (API Cloudflare)
▼
Secret wildcard-talos-lab-example-io-tls (ns envoy-gateway-system) ──► servi par Envoy sur :443
Le Certificateet le Secret naissent dans le namespace du Gateway
(envoy-gateway-system), pas dans cert-manager. Le renouvellement est automatique (à ~2/3 de
la durée de vie).
💡 Sans l'intégration Gateway API, le résultat s'obtient à la main : écrire un
Certificate (dnsNames: ["*.talos.lab.example.io"], issuerRef: letsencrypt-staging,
secretName: wildcard-talos-lab-example-io-tls) et laisser l'écouteur le référencer. Même
résultat, c'est juste toi qui crées l'objet au lieu de cert-manager.
gabarit du Secret token — ne jamais committer le vrai (préférer lab.env + platform-up.sh)
02-clusterissuer-staging.yaml
ClusterIssuer Let's Encrypt staging (quotas larges, cert non trusté)
03-clusterissuer-prod.yaml
ClusterIssuer Let's Encrypt prod (cert trusté) — celui référencé par le Gateway
04-gateway-https-example.yaml
illustration historique, à NE PAS appliquer (cf. ⚠️ Pièges)
⚠️ 04-gateway-https-example.yaml ne doit plus être appliqué. Il contient un Gatewaymain-gateway complet (mêmes name/namespace) : le kubectl applyremplacerait le
Gateway en place. La fusion est déjà faite dans ../envoy-gateway/Envoy-Proxy.yml
(écouteur https:443 + hostname wildcard + certificateRefs + annotation
cluster-issuer), et ../platform-up.sh n'applique que 02- et 03-. Garde ce fichier
comme support de lecture : il montre, isolée, la partie « HTTPS + cert-manager » du Gateway.
kubectlgetclusterissuer# les 2 émetteurs, READY=True
kubectl-nenvoy-gateway-systemgetcertificate# wildcard-…-tls, READY=True
kubectl-nenvoy-gateway-systemdescribecertificatewildcard-talos-lab-example-io-tls
# events : Order → Challenge → issued
kubectlgetchallenges-A# vide une fois validé# Quel certificat Envoy sert-il ? (aucune HTTPRoute nécessaire : on ne teste que le TLS)echo|openssls_client-connect192.168.56.200:443-servernamedemo.talos.lab.example.io2>/dev/null\|opensslx509-noout-subject-issuer-dates
# attendu : subject=CN=*.talos.lab.example.io, issuer=Let's Encrypt (et non "STAGING")
Test HTTPS de bout en bout : il faut un hostname qui porte une HTTPRoute (les routes de
démo de ../envoy-gateway/GW-Example.yml matchent par chemin, pas par hostname). Par exemple,
une fois l'addon Argo CD installé :
Challenge bloqué en pending → token Cloudflare (permissions ou zone), ou propagation
TXT lente. kubectl describe challenge <name> donne l'erreur exacte de l'API Cloudflare.
Secret cloudflare-api-token absent → CLOUDFLARE_API_TOKEN vide dans lab.env au
moment du platform-up.sh (le script le signale sans échouer). Crée le Secret, puis
kubectl -n envoy-gateway-system delete challenge --all pour relancer (les Order/Challenge
vivent dans le namespace du Certificate, donc du Gateway).
Certificate jamais créé malgré l'annotation → cert-manager ne tourne pas avec
config.enableGatewayAPI=true, ou il a démarré avant les CRD Gateway API :
kubectl -n cert-manager rollout restart deploy/cert-manager.
Navigateur qui refuse le certificat → tu es sur letsencrypt-staging, qui est le
défaut. Mets LAB_ACME_ISSUER=prod dans lab.env, relance platform-up.sh, puis
supprime le Secret pour forcer une réémission :
kubectl -n envoy-gateway-system delete secret wildcard-<domaine-en-tirets>-tls.
429 rateLimited en prod → le plafond de 5 certificats par semaine et par jeu
d'identifiants est atteint. Rien à corriger, rien à retenter : le message porte l'heure de
retry after et la fenêtre de 168 h est glissante. À noter que chaque vagrant destroy en
brûle un, puisque le wildcard ne vit que dans etcd. Repasse en LAB_ACME_ISSUER=staging en
attendant, ou sauvegarde le Secret avant de détruire (cf. ../../LISEZ-MOI.md §5).
SELF_SIGNED=true (le défaut) court-circuite toute cette page. Si
kubectl get clusterissuer répond no resources found et que la Gateway ne porte aucune
annotation cert-manager.io/cluster-issuer, rien n'est cassé : tu es simplement sur le
chemin auto-signé. Pose SELF_SIGNED=false dans lab.env, relance platform-up.sh, puis
supprime le Secret auto-signé résiduel pour que cert-manager émette le sien :
kubectl -n envoy-gateway-system delete secret <wildcard>-tls.
Ne pas appliquer 04-gateway-https-example.yaml (voir l'encart plus haut).
Un seul niveau de wildcard : *.talos.lab.example.io couvre argo.talos.lab.example.io, pas
a.b.talos.lab.example.io. Une route avec un hostname non couvert ne s'attachera pas à l'écouteur.
L'e-mail ACME versionné est neutre (admin@example.io) : platform-up.sh le remplace par
LAB_ACME_EMAIL (défaut admin@<LAB_DNS_ZONE>). En kubectl apply -f direct, tu appliques
l'adresse d'exemple — Let's Encrypt refuse certains domaines réservés.
DNS-only obligatoire côté Cloudflare : en mode « proxy orange », l'edge tenterait de
joindre 192.168.56.200 et l'accès casserait (le challenge DNS-01, lui, marcherait quand même).
🐮longhorn/ — replicated block storage (Longhorn 1.12) on Talos
Provides PersistentVolumes replicated across workers (StorageClass longhorn) carved out
of the node disks, with no hardware and no cloud provider. It is the lab's only HA storage:
a volume survives the loss of a node, unlike ../local-path-storage/.
Longhorn has two Talos-specific prerequisites, living in two different places, both to be
put in place BEFORE helm install — on top of the usual lab prerequisites:
Prerequisite
Why
Verify
iscsi-tools + util-linux-tools extensions in the installer (INSTALLER_IMAGE in lab.env)
Talos never adds an extension live: they are baked into the installer image. Without them, iscsiadm not found
talosctl -n 192.168.56.101 get extensions
rshared kubelet mount on /var/lib/longhorn (patch-longhorn.yaml) — applied by longhorn-up.sh
The Talos kubelet is containerized; longhorn-manager requires bidirectional mount propagation
talosctl -n 192.168.56.101 get mc -o yaml | grep -A6 extraMounts
helm + talosctl in PATH
the chart, and the machine-config patch above
helm version · talosctl version
Namespace longhorn-system with PodSecurity privileged
Longhorn pods are privileged (iSCSI, hostPath)
kubectl get ns longhorn-system --show-labels
../envoy-gateway/ + ../cert-manager/ (optional)
only to expose the UI over HTTPS
kubectl get gateway -n envoy-gateway-system
⚠️ cluster-up.sh does NOT apply patch-longhorn.yaml. It only passes
talos/patch-all.yaml, talos/patch-cp.yaml and talos/cni-${CNI}.yaml to gen config
(see talos/cluster-up.sh, step 1). Only the image comes from lab.env
(INSTALLER_IMAGE → --install-image). On a freshly built cluster, get mc therefore shows
noextraMounts at all — this is why longhorn-up.sh applies the patch itself, to the
workers, before the chart.
Pinned version: chart Longhorn 1.12.0. Talos: the version comes from TALOS_VERSION in
lab.env (v1.13.7 in lab.env.example) — the image factory refs below must carry that
version, not another one.
./_k8s/longhorn/longhorn-up.sh
Idempotent: re-runnable without breaking anything (helm upgrade --install, and the machine
config patch is only applied where it is missing). It covers steps 2 to 4 below, reads
WORKERS/NETWORK from lab.env to find the workers, and aligns the block replica count with
the number of workers (REPLICAS=… to force it). LONGHORN_VERSION=… overrides the chart
version.
⚠️ Step 1 is the only one that cannot be automated: the installer image is chosen
before the cluster exists, since an extension is baked into it. longhorn-up.sh only
checks that iscsi-tools is there and refuses to install anything if it is not.
The repo already ships a ready-made factory ref in lab.env.example (schematic
613e1592…, deterministic: same extensions ⇒ same ID). Regenerate it only if you
change schematic.yaml:
# in lab.env — the RESOLVED value (the full ID is already in lab.env.example), and above all# NOT ${SCHEMATIC_ID}: lab.env is not a script, its parser knows nothing about that# variable and would write "factory.talos.dev/installer/:v1.13.7".INSTALLER_IMAGE=factory.talos.dev/installer/<schematic-id>:v1.13.7
This is where classic vs longhorn is decided: an empty INSTALLER_IMAGE ⇒ cluster-up.sh
falls back to ghcr.io/siderolabs/installer:${TALOS_VERSION} (no extensions). The boot ISO does
not change: extensions are pulled from the installer at disk-install time.
2. Kubelet mount (rshared) on the workers — automated by longhorn-up.sh#
Control planes are tainted node-role.kubernetes.io/control-plane:NoSchedule: only the
workers need the mount.
# Cluster already running — applied with NO reboot, one worker at a time:foripin192.168.56.101192.168.56.102192.168.56.103;dotalosctl-n"$ip"patchmc--patch@_k8s/longhorn/patch-longhorn.yaml
done
Fresh cluster: inject the patch at gen config time (manual)
talosctlgenconfigtalos-labhttps://192.168.56.5:6443--install-disk/dev/sda\--install-image"$INSTALLER_IMAGE"\--config-patch@talos/patch-all.yaml\--config-patch-control-plane@talos/patch-cp.yaml\--config-patch-control-plane@talos/cni-flannel.yaml\--config-patch@_k8s/longhorn/patch-longhorn.yaml\--output-dir_out
talosctlvalidate--config_out/controlplane.yaml--modemetal
# then apply-config + bootstrap (see talos/cluster-up.sh)
Existing cluster without the extensions: upgrade to the factory installer (--preserve to
keep the data), then the mount patch:
The install disk is 20 GB and shared with the OS (Vagrantfile,
DISK_SIZE_MB = 20480). Stacking 3-replica volumes on it triggers
ReplicaSchedulingFailure. longhorn-r1 cuts consumption by ~3 for the cases where block
replication is pointless: rebuildable data (Prometheus, Loki) or data already replicated by the
application (CloudNativePG, 3 instances). Defined once here, consumed elsewhere.
ℹ️ For a critical database, stay on longhorn (3 replicas) or explicitly delegate
resilience to the application.
Longhorn 1.10+ recommends a dedicated disk on /var/mnt/longhorn. Here, by default, we stay on
/var/lib/longhorn (the lab's single disk). To do it properly:
VirtualBox: attach one extra .vdi per worker (SATA controller, next port) — this needs
an addition to the Vagrantfile (the unless File.exist?(disk_path) block).
Talos: a UserVolumeConfig document (mounts automatically on /var/mnt/<name>), then
point kubelet.extraMountsanddefaultDataPath at /var/mnt/longhorn:
apiVersion:v1alpha1kind:UserVolumeConfigname:longhornprovisioning:diskSelector:match:disk.transport == "sata" && !system_disk# the 2nd disk, not /dev/sdagrow:true
longhorn-up.sh already applied the HTTPRoute (its step [5/5]). To re-apply it alone:
kubectlapply-f_k8s/longhorn/httproute.yaml
🌐 Domain: the manifest carries the neutral domain talos.lab.example.io (public repo).
longhorn-up.sh substitutes LAB_DOMAIN on the fly; applying the file by hand, as above,
keeps the neutral domain. Substitute it yourself:
The wildcard cert *.talos.lab.example.io is already carried by the https listener: nothing
to issue here, whichever TLS mode the lab runs (self-signed by default, or cert-manager — the
Secret has the same name either way, see ../self-signed/README.md).
⚠️ The Longhorn UI has no authentication whatsoever. Exposed like this, it is reachable by
anyone who can reach the VIP (over Tailscale) — and it lets them delete volumes. To protect it:
an Envoy Gateway SecurityPolicy (Basic Auth / OIDC) targeting this HTTPRoute.
The kubelet mount does not come from the cluster bootstrap: cluster-up.sh ignores
patch-longhorn.yaml (see Prerequisites) — longhorn-up.sh is what applies it. Installing
the chart by hand, without that patch, gives a longhorn-manager failing on mount
propagation even though the extensions are there.
Two default StorageClasses if ../local-path-storage/ is installed too: values.yaml
sets persistence.defaultClass: true (⇒ longhorn) and local-path-storage.yaml annotates
local-path with is-default-class: "true". A PVC without storageClassName then becomes
non-deterministic. Pick a single default:
kubectlannotatestorageclasslocal-pathstorageclass.kubernetes.io/is-default-class-
# or, the other way around: helm upgrade ... --set persistence.defaultClass=false
defaultReplicaCount > number of workers → volumes stuck Degraded. Align it with
WORKERS in lab.env; with 1 worker, set 1.
Missing extensions → CSI pods in CrashLoopBackOff, iscsiadm not found errors.
Factory image ref on the wrong version: it must carry the installed version
(TALOS_VERSION from lab.env, v1.13.7). An upgrade to
ghcr.io/siderolabs/installer:v1.13.7 (classic) would strip the extensions.
Talos upgrade of a node that stores data: always --preserve, otherwise the EPHEMERAL
partition (hence /var/lib/longhorn) is wiped.
Shared OS disk (20 GB): Longhorn on /var/lib/longhorn eats the same partition as the OS
and the container images → watch for DiskPressure, prefer longhorn-r1, or move to a
dedicated disk (above).
Uninstall: flip the Longhorn setting deleting-confirmation-flag to true before
helm uninstall, otherwise the deletion hangs forever.
talos/UPGRADE.md §5 — adding the extensions to an already installed cluster.
Source: _k8s/longhorn/README.md14 sections
_k8s/longhorn/LISEZ-MOI.md
🐮longhorn/ — stockage bloc répliqué (Longhorn 1.12) sur Talos
Fournit des PersistentVolumerépliqués entre workers (StorageClass longhorn) à partir
du disque des nodes, sans matériel ni cloud provider. C'est le seul stockage HA du lab :
un volume survit à la perte d'un node, contrairement à ../local-path-storage/.
Longhorn a deux prérequis spécifiques à Talos, dans deux endroits différents, à poser
AVANT le helm install — plus les prérequis habituels du lab :
Prérequis
Pourquoi
Vérifier
Extensions iscsi-tools + util-linux-tools dans l'installeur (INSTALLER_IMAGE de lab.env)
Talos n'ajoute pas d'extension à chaud : elles sont bakées dans l'image d'installeur. Sans elles, iscsiadm not found
talosctl -n 192.168.56.101 get extensions
Montage kubelet rshared sur /var/lib/longhorn (patch-longhorn.yaml) — appliqué par longhorn-up.sh
Le kubelet Talos est conteneurisé ; longhorn-manager exige une propagation de montage bidirectionnelle
talosctl -n 192.168.56.101 get mc -o yaml | grep -A6 extraMounts
helm + talosctl dans le PATH
le chart, et le patch machine config ci-dessus
helm version · talosctl version
Namespace longhorn-system en PodSecurity privileged
les pods Longhorn sont privilégiés (iSCSI, hostPath)
kubectl get ns longhorn-system --show-labels
../envoy-gateway/ + ../cert-manager/ (optionnel)
uniquement pour exposer l'UI en HTTPS
kubectl get gateway -n envoy-gateway-system
⚠️ cluster-up.sh n'applique PAS patch-longhorn.yaml. Il ne passe que
talos/patch-all.yaml, talos/patch-cp.yaml et talos/cni-${CNI}.yaml au gen config
(cf. talos/cluster-up.sh, étape 1). Seule l'image vient de lab.env
(INSTALLER_IMAGE → --install-image). Sur un cluster fraîchement monté, get mc ne montre
donc aucunextraMounts — c'est pour ça que longhorn-up.sh applique lui-même le patch
aux workers, avant le chart.
Version épinglée : chart Longhorn 1.12.0. Talos : la version vient de TALOS_VERSION
dans lab.env (v1.13.7 dans lab.env.example) — les refs d'image factory ci-dessous
doivent porter cette version, pas une autre.
./_k8s/longhorn/longhorn-up.sh
Idempotent : relançable sans casse (helm upgrade --install, et le patch machine config n'est
posé que là où il manque). Il couvre les étapes 2 à 4 ci-dessous, lit WORKERS/NETWORK
dans lab.env pour trouver les workers, et aligne le nombre de réplicas bloc sur le nombre de
workers (REPLICAS=… pour forcer). LONGHORN_VERSION=… surcharge la version du chart.
⚠️ L'étape 1 est la seule non automatisable : l'image d'installeur se choisit avant que
le cluster existe, puisqu'une extension y est cuite. longhorn-up.sh se contente de
vérifier qu'iscsi-tools est là, et refuse d'installer quoi que ce soit sinon.
1. Installeur avec les extensions (Image Factory)#
Le dépôt livre déjà une ref factory prête dans lab.env.example (schematic
613e1592…, déterministe : mêmes extensions ⇒ même ID). À régénérer seulement si tu
modifies schematic.yaml :
# dans lab.env — la valeur RÉSOLUE (l'ID complet est déjà dans lab.env.example), et surtout# PAS ${SCHEMATIC_ID} : lab.env n'est pas un script, son parseur ne connaît pas cette# variable et écrirait "factory.talos.dev/installer/:v1.13.7".INSTALLER_IMAGE=factory.talos.dev/installer/<schematic-id>:v1.13.7
C'est le point de choix classic vs longhorn : INSTALLER_IMAGE vide ⇒ cluster-up.sh
retombe sur ghcr.io/siderolabs/installer:${TALOS_VERSION} (aucune extension). L'ISO de boot
ne change pas : les extensions sont tirées de l'installeur au moment de l'installation disque.
2. Montage kubelet (rshared) sur les workers — automatisé par longhorn-up.sh#
Les CP sont taintés node-role.kubernetes.io/control-plane:NoSchedule : seuls les workers
ont besoin du montage.
# Cluster déjà en route — appliqué SANS reboot, worker par worker :foripin192.168.56.101192.168.56.102192.168.56.103;dotalosctl-n"$ip"patchmc--patch@_k8s/longhorn/patch-longhorn.yaml
done
Cluster neuf : injecter le patch au gen config (manuel)
talosctlgenconfigtalos-labhttps://192.168.56.5:6443--install-disk/dev/sda\--install-image"$INSTALLER_IMAGE"\--config-patch@talos/patch-all.yaml\--config-patch-control-plane@talos/patch-cp.yaml\--config-patch-control-plane@talos/cni-flannel.yaml\--config-patch@_k8s/longhorn/patch-longhorn.yaml\--output-dir_out
talosctlvalidate--config_out/controlplane.yaml--modemetal
# puis apply-config + bootstrap (cf. talos/cluster-up.sh)
Cluster existant sans les extensions : upgrade vers l'installeur factory (--preserve
pour garder les données), puis le patch de montage :
4. Chart Helm + StorageClass socle — automatisé par longhorn-up.sh#
helmrepoaddlonghornhttps://charts.longhorn.io&&helmrepoupdate
# --version : épingle ; vérifier la dernière sur charts.longhorn.io
helminstalllonghornlonghorn/longhorn\--namespacelonghorn-system\--version1.12.0\-f_k8s/longhorn/values.yaml
kubectl-nlonghorn-systemrolloutstatusdeploy/longhorn-driver-deployer
kubectlapply-f_k8s/longhorn/longhorn-r1-storageclass.yaml
Le disque d'install fait 20 Go et est partagé avec l'OS (Vagrantfile,
DISK_SIZE_MB = 20480). Empiler des volumes 3-réplicas y déclenche des
ReplicaSchedulingFailure. longhorn-r1 divise la conso par ~3 pour les cas où la
réplication bloc est superflue : donnée reconstructible (Prometheus, Loki) ou déjà répliquée
par l'appli (CloudNativePG, 3 instances). Définie une seule fois ici, consommée ailleurs.
ℹ️ Sur une base critique, rester sur longhorn (3 réplicas) ou déléguer explicitement
la résilience à l'application.
Longhorn 1.10+ recommande un disque dédié en /var/mnt/longhorn. Ici, par défaut, on reste
sur /var/lib/longhorn (disque unique du lab). Pour faire propre :
VirtualBox : attacher un .vdi supplémentaire par worker (contrôleur SATA, port
suivant) — nécessite un ajout dans le Vagrantfile (bloc unless File.exist?(disk_path)).
Talos : un document UserVolumeConfig (monte automatiquement en /var/mnt/<name>),
puis adapter kubelet.extraMountsetdefaultDataPath sur /var/mnt/longhorn :
apiVersion:v1alpha1kind:UserVolumeConfigname:longhornprovisioning:diskSelector:match:disk.transport == "sata" && !system_disk# le 2e disque, pas /dev/sdagrow:true
longhorn-up.sh a déjà appliqué l'HTTPRoute (son étape [5/5]). Pour la réappliquer seule :
kubectlapply-f_k8s/longhorn/httproute.yaml
🌐 Domaine : le manifeste porte le domaine neutre talos.lab.example.io (dépôt public).
longhorn-up.sh y substitue LAB_DOMAIN à la volée ; appliqué à la main comme ci-dessus, le
domaine neutre reste. Substitue-le toi-même :
Cert wildcard *.talos.lab.example.io déjà porté par l'écouteur https : rien à émettre ici,
quel que soit le mode TLS du lab (auto-signé par défaut, ou cert-manager — le Secret porte le
même nom dans les deux cas, cf. ../self-signed/LISEZ-MOI.md).
⚠️ L'UI Longhorn n'a aucune authentification. Exposée ainsi, elle est accessible à
quiconque atteint le VIP (via Tailscale) — et elle permet de supprimer des volumes. Pour la
protéger : SecurityPolicy Envoy Gateway (Basic Auth / OIDC) ciblant cette HTTPRoute.
Le montage kubelet ne vient pas du bootstrap du cluster : cluster-up.sh ignore
patch-longhorn.yaml (cf. Prérequis) — c'est longhorn-up.sh qui l'applique. Installer le
chart à la main sans ce patch donne un longhorn-manager en erreur de propagation de montage
alors que les extensions sont bien là.
Deux StorageClass par défaut si ../local-path-storage/ est aussi installé :
values.yaml pose persistence.defaultClass: true (⇒ longhorn) et
local-path-storage.yaml annote local-path avec is-default-class: "true". Un PVC sans
storageClassName devient alors non déterministe. Choisir un seul défaut :
kubectlannotatestorageclasslocal-pathstorageclass.kubernetes.io/is-default-class-
# ou, dans l'autre sens : helm upgrade ... --set persistence.defaultClass=false
defaultReplicaCount > nombre de workers → volumes coincés en Degraded. Aligner sur
WORKERS de lab.env ; à 1 worker, mettre 1.
Extensions manquantes → pods CSI en CrashLoopBackOff, erreurs iscsiadm not found.
Ref d'image factory à la mauvaise version : elle doit porter la version installée
(TALOS_VERSION de lab.env, v1.13.7). Un upgrade vers
ghcr.io/siderolabs/installer:v1.13.7 (classic) retirerait les extensions.
Upgrade Talos d'un node qui stocke des données : toujours --preserve, sinon la
partition EPHEMERAL (donc /var/lib/longhorn) est effacée.
Disque OS partagé (20 Go) : Longhorn sur /var/lib/longhorn consomme la même partition
que l'OS et les images conteneurs → surveiller DiskPressure, préférer longhorn-r1, ou
passer au disque dédié (ci-dessus).
Désinstallation : passer le setting Longhorn deleting-confirmation-flag à true
avant helm uninstall, sinon la suppression reste bloquée.
talos/MISE-A-JOUR.md §5 — ajouter les extensions à un cluster déjà installé.
Source : _k8s/longhorn/LISEZ-MOI.md14 sections
_k8s/local-path-storage/README.md
📁local-path-storage/ — dynamic local storage (no Longhorn), Talos-adapted
Deploys Rancher local-path-provisionerv0.0.30 and a default local-path StorageClass: PVs carved out of the worker's disk
(/var/local-path-provisioner). This is the lab's "no Longhorn" alternative — zero Talos
extension, zero CSI, two resources and provisioning just works.
Talos ships no storage provisioner at all: kubectl get storageclass returns nothing and
every PVC stays Pending. Components that require a PVC (CloudNativePG does not support
emptyDir for PGDATA, MinIO wants a /data) never start at all. This provisioner fills the gap
with no external dependency.
⚠️ NODE-LOCAL storage, not replicated. A PV lives on one single worker. It survives
a pod restart / reschedule (as long as the pod comes back on the same node), but it is lost
if that node dies. No HA at the storage level: keep it for rebuildable data or for
"knowingly ephemeral" use cases. For replicated storage, see ../longhorn/.
Who uses it in this lab:
Addon
Usage
../minio-s3/
1 PVC, 10 Gi (standalone)
../minio-s3/cluster/
4 PVCs, 10 Gi — MinIO does its own resilience (erasure coding) on top
ℹ️ ../cloudnative-pg/ has no local-path variant: cluster-demo.yaml mandates
storageClass: longhorn-r1 and cloudnative-pg-up.sh bails out if that StorageClass is
missing. A "3 PostgreSQL nodes on local-path" variant is an unimplemented idea.
Same for ../databasement/, which stays on emptyDir (values.yaml).
The vendored manifest (local-path-storage.yaml) starts from
upstream v0.0.30. The first two deviations are Talos constraints, the third one is a
lab choice:
PVCs without a storageClassName use it automatically.
ℹ️ Same Pod Security / read-only FS pitfall as the Trivy node-collector, except here it is
fixable: the hostPath targets /var (writable), not /etc/systemd.
On Talos, stay under /var. You can map a path per node ("node":"talos-w1"), for
instance onto an extra disk mounted by the Talos machine config. After editing:
kubectl -n local-path-storage rollout restart deploy/local-path-provisioner.
reclaimPolicy: Delete — the PV directory is deleted along with the PVC. Use Retain
to keep the data.
volumeBindingMode: WaitForFirstConsumer — the PV is only provisioned when the pod is
scheduled (storage follows the pod onto its node). Keep it.
The size requested by a PVC is NOT enforced. A local-path PV is a plain hostPath
directory: requests.storage: 10Gi is purely declarative, nothing caps the writes. A workload
can fill the worker's /var partition up to DiskPressure (and pod eviction). Measured on
this lab: ~16.9 GB of allocatable ephemeral-storage per node for a 20 GB disk
(Vagrantfile, DISK_SIZE_MB = 20480) — two 10 Gi PVCs "fit" side by side on paper, not in
reality. Watch it:
Two default StorageClasses if ../longhorn/ is installed alongside: local-path is
annotated is-default-class: "true" and longhorn/values.yaml sets
persistence.defaultClass: true. A PVC without storageClassName becomes non-deterministic.
Drop one of the two defaults:
The helper pod runs image: busybox with no tag (so :latest, see
local-path-storage.yaml). That violates the disallow-latest-tag policy of
../kyverno/policies/02-disallow-latest-tag.yaml: both of its rules (tag present, tag ≠
latest) will surface a failing PolicyReport on helper-pod. The policy runs in Audit
mode → nothing is blocked, but it is an expected "offender" in the Policy Reporter UI.
PVs stuck after uninstall: already provisioned PVs (and their directories under
/var/local-path-provisioner) are not cleaned up while PVCs still reference them.
Delete the consuming workloads/PVCs first.
📁local-path-storage/ — stockage local dynamique (sans Longhorn), adapté Talos
Déploie Rancher local-path-provisionerv0.0.30 et une StorageClass local-path par défaut : des PV taillés dans le disque du
worker (/var/local-path-provisioner). C'est l'alternative « sans Longhorn » du lab —
zéro extension Talos, zéro CSI, deux ressources et c'est provisionné.
Talos n'embarque aucun provisioner de stockage : kubectl get storageclass renvoie vide et
tout PVC reste Pending. Les addons qui exigent un PVC (CloudNativePG ne supporte pas
emptyDir pour PGDATA, MinIO veut un /data) ne démarrent pas du tout. Ce provisioner comble
ce manque sans dépendance externe.
⚠️ Stockage NODE-LOCAL, non répliqué. Un PV vit sur un seul worker. Il survit au
redémarrage / reschedule d'un pod (tant qu'il revient sur le même node), mais il est perdu
si ce node meurt. Aucune HA au niveau stockage : à réserver aux données reconstructibles ou
aux usages « éphémères assumés ». Pour du répliqué, voir ../longhorn/.
Qui l'utilise dans ce lab :
Addon
Usage
../minio-s3/
1 PVC 10 Gi (standalone)
../minio-s3/cluster/
4 PVC 10 Gi — MinIO fait sa propre résilience (erasure coding) par-dessus
ℹ️ ../cloudnative-pg/n'a pas de variante local-path : cluster-demo.yaml impose
storageClass: longhorn-r1 et cloudnative-pg-up.sh s'arrête si cette StorageClass est
absente. Une variante « 3 nœuds PostgreSQL sur local-path » est une piste non implémentée.
Idem ../databasement/, qui reste sur emptyDir (values.yaml).
Le manifeste vendorisé (local-path-storage.yaml) part de
l'upstream v0.0.30. Les deux premiers écarts sont des contraintes Talos, le troisième
est un choix du lab :
Sur Talos, / et /etc sont read-only ; seule la partition /var est inscriptible. Un helper-pod qui tente mkdir /opt/... échoue (read-only file system).
2
Namespace local-path-storage en PodSecurity privileged
Les helper-pods (création/suppression des dossiers de PV) montent du hostPath, refusé par le défaut cluster Talos baseline.
3
StorageClass local-path marquée par défaut (is-default-class)
Les PVC sans storageClassName l'utilisent automatiquement.
ℹ️ Même piège Pod Security / FS read-only que le node-collector Trivy, mais ici c'est
résoluble : le hostPath cible /var (inscriptible), pas /etc/systemd.
Sur Talos, rester sous /var. On peut mapper un chemin par node ("node":"talos-w1"),
par exemple vers un disque supplémentaire monté par la machine config Talos. Après édition :
kubectl -n local-path-storage rollout restart deploy/local-path-provisioner.
reclaimPolicy: Delete — le dossier du PV est supprimé avec le PVC. Retain pour
conserver les données.
volumeBindingMode: WaitForFirstConsumer — le PV n'est provisionné qu'au scheduling du
pod (le stockage suit le pod sur son node). À conserver.
La taille demandée par un PVC n'est PAS appliquée. Un PV local-path est un simple dossier
hostPath : requests.storage: 10Gi est purement déclaratif, rien ne borne l'écriture. Un
workload peut remplir la partition /var du worker jusqu'au DiskPressure (et l'éviction des
pods). Mesuré sur ce lab : ~16,9 Go d'ephemeral-storage allocatable par node pour un
disque de 20 Go (Vagrantfile, DISK_SIZE_MB = 20480) — deux PVC de 10 Gi « tiennent » côte
à côte sur le papier, pas dans la réalité. Surveiller :
Deux StorageClass par défaut si ../longhorn/ est installé en parallèle : local-path
est annotée is-default-class: "true" et longhorn/values.yaml pose
persistence.defaultClass: true. Un PVC sans storageClassName devient non déterministe.
Retirer un des deux défauts :
Le helper-pod tourne sur image: busybox sans tag (donc :latest, cf.
local-path-storage.yaml). Ça viole la policy disallow-latest-tag de
../kyverno/policies/02-disallow-latest-tag.yaml : ses deux règles (tag présent, tag ≠
latest) remonteront un PolicyReport en échec sur helper-pod. La policy est en mode
Audit → rien n'est bloqué, mais c'est un « coupable » attendu dans l'UI Policy Reporter.
PV coincés après désinstallation : les PV déjà provisionnés (et leurs dossiers sous
/var/local-path-provisioner) ne sont pas nettoyés si des PVC les référencent encore.
Supprimer d'abord les workloads/PVC consommateurs.
🪣minio-s3/ — standalone MinIO (S3 + admin console) on local-path
An S3-compatible endpoint inside the cluster, in a single pod, with a full admin
console (Pigsty fork). This is the simple version: one local-path PVC, zero resilience.
The resilient version lives in cluster/.
Get a local S3 to test backups, SDKs, mc, bucket policies — with no cloud account. Two
hostnames exposed over HTTPS via main-gateway (wildcard *.talos.lab.example.io):
Service
URL
Container port
S3 API
https://minio.talos.lab.example.io
9000
Admin console
https://minio-console.talos.lab.example.io
9001
Inside the cluster: http://minio.minio-s3.svc.cluster.local:9000.
ℹ️ The lab backups no longer target this standalone. Since the move to the MinIO cluster,
../cloudnative-pg/pg-backup-up.sh and pg-app-backup-cnpg-up.sh point at
http://minio.minio-cluster.svc.cluster.local:9000. This directory remains the simple sandbox
(and the teaching component for "before/after erasure coding").
Checks kubectl, the apiserver and the presence of the local-path StorageClass.
Creates the minio-s3 namespace and the minio-creds Secret (root-user / root-password) —
never overwritten if it exists: re-running the script does not change the password.
Applies minio-s3.yaml: 10 Gi PVC, Deployment (strategy: Recreate, since the RWO volume
cannot take two pods), ClusterIP Service, two HTTPRoutes.
Waits for the rollout (180 s) then prints the URLs and the root credentials in clear text
on stdout (see Pitfalls).
Bitnami (bitnami/minio) has relied since August 2025 on frozen images
(bitnamilegacy/*, no longer updated).
Upstream minio/miniogutted the community console around
RELEASE.2025-05-24 (only an object browser is left: no more user / bucket / policy /
lifecycle management from the web), then the repo was archived as
"no longer maintained" (Feb 2026).
Pigsty rebuilds the MinIO server and restores the full admin console → a recent AND
manageable image. It is the most active fork (see the "MinIO is Dead, Long Live MinIO"
post).
Other options: upstream pinned at RELEASE.2025-04-22T22-12-26Z (the last release with the
official admin console, but frozen), other console forks (huncrys/minio-console,
georgmangold/console), the paid AIStor edition, or simply the mc CLI.
minio-up.sh prints the root user AND password in clear text on stdout (end of the run).
That ends up in your shell history, CI logs, a screenshot… Prefer reading them back from the
Secret (table above), and remember to scrub the output if you share it.
No resilience at all. A 1-replica Deployment + 1 local-path PVC = node-local storage.
If the worker hosting the PV dies, the objects are gone. For real object resilience, use
cluster/ (4 drives, EC:2, on local-path as well — no need for Longhorn:
MinIO replicates on its own).
The 10 Gi of the PVC is not a limit.local-path provisions a hostPath directory: nothing
stops MinIO from filling the worker's /var partition. The allocatable ephemeral-storage
measured on this lab is ~16.9 GB/node (20 GB disk shared with the OS and the container
images) → filling a bucket triggers DiskPressure and pod eviction on that node. Watch
kubectl describe node <worker> | grep -i pressure.
The minio-creds Secret is not in git (the script creates it). Losing it means losing root
access: kubectl -n minio-s3 delete secret minio-creds then re-running the script regenerates
a password, but MinIO keeps the old one until the pod is recreated.
Console behind a separate hostname: MINIO_BROWSER_REDIRECT_URL carries
https://minio-console.talos.lab.example.io in minio-s3.yaml — the neutral domain of the
public repo. minio-up.sh substitutes it, along with the HTTPRoute hostnames, from LAB_DOMAIN
(lab.env). A directkubectl apply -f minio-s3.yaml keeps the example domain and breaks
the login redirects. See ../README.md.
cluster/ — the distributed 4-node variant (erasure coding).
Source: _k8s/minio-s3/README.md10 sections
_k8s/minio-s3/LISEZ-MOI.md
🪣minio-s3/ — MinIO standalone (S3 + console d'admin) sur local-path
Un endpoint compatible S3 dans le cluster, en un seul pod, avec une console
d'administration complète (fork Pigsty). C'est la version simple : un PVC local-path,
aucune résilience. La version résiliente est dans cluster/.
Avoir un S3 local pour tester des backups, des SDK, mc, des politiques de buckets — sans
compte cloud. Deux hostnames exposés en HTTPS via main-gateway (wildcard *.talos.lab.example.io) :
Service
URL
Port conteneur
API S3
https://minio.talos.lab.example.io
9000
Console admin
https://minio-console.talos.lab.example.io
9001
En interne au cluster : http://minio.minio-s3.svc.cluster.local:9000.
ℹ️ Les backups du lab ne visent plus ce standalone. Depuis le passage au cluster MinIO,
../cloudnative-pg/pg-backup-up.sh et pg-app-backup-cnpg-up.sh pointent
http://minio.minio-cluster.svc.cluster.local:9000. Ce dossier reste le bac à sable simple
(et la brique pédagogique « avant/après erasure coding »).
Vérifie kubectl, l'apiserver et la présence de la StorageClass local-path.
Crée le namespace minio-s3 et le Secret minio-creds (root-user / root-password) —
jamais écrasé s'il existe : relancer le script ne change pas le mot de passe.
Applique minio-s3.yaml : PVC 10 Gi, Deployment (strategy: Recreate, car le volume RWO
n'accepte pas deux pods), Service ClusterIP, deux HTTPRoute.
Attend le rollout (180 s) puis affiche les URL et les identifiants root en clair sur
stdout (cf. Pièges).
Bitnami (bitnami/minio) s'appuie depuis août 2025 sur des images gelées
(bitnamilegacy/*, plus mises à jour).
Upstream minio/minio a amputé la console communautaire vers
RELEASE.2025-05-24 (il ne reste qu'un navigateur d'objets : plus de gestion users /
buckets / policies / lifecycle depuis le web), puis le dépôt a été archivé
« no longer maintained » (fév. 2026).
Pigsty rebuild le serveur MinIO et restaure la console d'admin complète → image
récente ET administrable. C'est le fork le plus actif (billet « MinIO is Dead, Long
Live MinIO »).
Alternatives possibles : upstream épinglé RELEASE.2025-04-22T22-12-26Z (dernière release avec
la console admin officielle, mais figé), autres forks de console (huncrys/minio-console,
georgmangold/console), édition payante AIStor, ou simplement le CLI mc.
Le cert wildcard est émis par Let's Encrypt staging → avertissement TLS à accepter dans le
navigateur, et --insecure pour mc (cf. ../cert-manager/).
mcaliassetlabhttps://minio.talos.lab.example.io<user><pass>--insecure
mcmblab/mon-bucket--insecure# créer un bucket
mcadminuseraddlabbob<mot-de-passe>--insecure# gérer les users
mclslab--insecure
minio-up.sh affiche l'utilisateur ET le mot de passe root en clair sur stdout (fin de
run). Ça finit dans l'historique du terminal, les logs de CI, une capture d'écran… Préférer
les relire depuis le Secret (tableau ci-dessus) et penser à nettoyer la sortie si tu la
partages.
Aucune résilience. Deployment 1 replica + 1 PVC local-path = stockage node-local.
Si le worker qui héberge le PV meurt, les objets sont perdus. Pour de la vraie résilience
objet, utiliser cluster/ (4 drives, EC:2, sur local-path lui aussi — pas
besoin de Longhorn : MinIO se réplique tout seul).
Les 10 Gi du PVC ne sont pas une limite.local-path provisionne un dossier hostPath :
rien n'empêche MinIO de remplir la partition /var du worker. L'ephemeral-storage
allocatable mesuré sur ce lab est de ~16,9 Go/node (disque de 20 Go partagé avec l'OS et
les images conteneurs) → remplir un bucket déclenche du DiskPressure et l'éviction de
pods sur ce node. Surveiller kubectl describe node <worker> | grep -i pressure.
Le Secret minio-creds n'est pas dans git (créé par le script). Le perdre = perdre
l'accès root : kubectl -n minio-s3 delete secret minio-creds puis relancer le script
régénère un mot de passe, mais MinIO garde l'ancien tant que le pod n'est pas recréé.
Console derrière un hostname distinct : MINIO_BROWSER_REDIRECT_URL porte
https://minio-console.talos.lab.example.io dans minio-s3.yaml — domaine neutre du dépôt
public. minio-up.sh la substitue avec les hostnames des HTTPRoute depuis LAB_DOMAIN
(lab.env). Un kubectl apply -f minio-s3.yamldirect garde le domaine d'exemple et casse
les redirections de login. Cf. ../LISEZ-MOI.md.
cluster/ — la variante distribuée 4 nœuds (erasure coding).
Source : _k8s/minio-s3/LISEZ-MOI.md10 sections
_k8s/minio-s3/cluster/README.md
🧺minio-s3/cluster/ — distributed 4-node MinIO (erasure coding) on local-path
The resilient variant of the standalone MinIO (../): a 4-pod StatefulSet, 1 drive
(local-path PVC) per pod, 1 pod per worker. MinIO erasure-codes objects across the 4
drives → object storage survives node losses without Longhorn, exactly the way
CloudNativePG handles Postgres replication itself.
⚠️ BLOCKING prerequisite: 4 Ready workers. The repo default (lab.env.example) is
WORKERS=3 → with that topology this component cannot start at all. See Prerequisites.
This is the lab's "for real" S3: the target of the PostgreSQL backups
(../../cloudnative-pg/pg-backup-up.sh and pg-app-backup-cnpg-up.sh point at
http://minio.minio-cluster.svc.cluster.local:9000), and the teaching demo of erasure coding
against the standalone.
Standalone (../)
Cluster (here)
Workload
Deployment, 1 replica
StatefulSet, 4 pods
Drives
1 (local-path 10 Gi)
4 (1 PVC 10 Gi/pod, 1/worker)
Erasure coding
❌
✅ EC:2 (2 parity blocks)
Resilience
none (losing the node = losing the data)
tolerates ~2 nodes/drives down
Workers required
1
4
Namespace
minio-s3
minio-cluster (they coexist)
Exposure (HTTPS via main-gateway, wildcard *.talos.lab.example.io):
replicas: 4 + requiredDuringScheduling anti-affinity on kubernetes.io/hostname (max 1 pod per node); control planes are tainted NoSchedule so they do not count
kubectl get nodes -l '!node-role.kubernetes.io/control-plane'
the 4 PVCs of the volumeClaimTemplates; the script bails out without it
kubectl get storageclass local-path
main-gateway + https listener + wildcard cert
both HTTPRoutes
kubectl get gateway -n envoy-gateway-system
DNS minio-cluster + minio-cluster-console → 192.168.56.200
to reach the Envoy VIP
getent hosts minio-cluster.talos.lab.example.io
⚠️ Set WORKERS=4 (or more) in lab.env before building the cluster.lab.env.example ships WORKERS=3, and minio-cluster-up.sh only warns (a plain
echo, not an exit) when there are fewer than 4 workers. It applies the manifest anyway:
the 4th pod stays Pending forever (strict anti-affinity, no eligible node left), so the
rollout status --timeout=300sfails after 5 minutes and the deployment starts at best
degraded from day one (3 drives out of 4, zero margin: one more failure and the erasure set
loses its write quorum). Changing WORKERS requires a vagrant destroy and a cluster rebuild
(see CLAUDE.md): decide beforehand.
StatefulSet minio (podManagementPolicy: Parallel — the 4 pods boot TOGETHER and wait for each other)
minio-0 @ worker A ─ PVC data-minio-0 (local-path 10Gi) ┐
minio-1 @ worker B ─ PVC data-minio-1 ├─ 1 pool, 1 erasure set of 4, EC:2
minio-2 @ worker C ─ PVC data-minio-2 │
minio-3 @ worker D ─ PVC data-minio-3 ┘
▲ peer discovery via the HEADLESS Service minio-hl:
server http://minio-{0...3}.minio-hl.minio-cluster.svc.cluster.local:9000/data
Service minio (ClusterIP) ── balances across the 4 pods ── HTTPRoutes (API + console)
Key points of the manifest:
podManagementPolicy: Parallel (mandatory): with OrderedReady, minio-0 would never be
"ready" without its peers → deadlock. In parallel, all 4 boot and form the quorum.
Headless Service minio-hl (clusterIP: None, publishNotReadyAddresses: true): stable
per-pod DNS, resolved before the pods are ready.
Then repoint the backup jobs (MINIO_ENDPOINT / endpointURL) at
http://minio.minio-cluster.svc.cluster.local:9000 — already the case for the scripts in
../../cloudnative-pg/ — and decommission the standalone.
Fewer than 4 workers = an install that never converges: the 4th pod stays Pending
(required… anti-affinity), rollout status fails after 5 min, and the erasure set runs at
3/4 drives right from the start. The script does not fail at that point (just an echo
warning, minio-cluster-up.sh): do not read its quiet start as proof the topology is right.
minio-cluster-up.sh prints the root user and password in clear text on stdout.
Read them back from the Secret instead (Access table).
The 4 × 10 Gi are not quotas.local-path = a hostPath directory, the PVC size is never
enforced. The allocatable ephemeral-storage measured here is ~16.9 GB per node (20 GB disk
shared with the OS and the images): filling the buckets causes DiskPressure and pod
eviction on the affected workers — hence potentially the simultaneous loss of several MinIO
drives. Watch it:
Node-local drives: losing a worker = losing a drive. EC:2 absorbs 2 losses, no more; a
vagrant destroy absorbs 4 (and the data with them).
Scaling: MinIO grows by adding server pools (≥ 4 drives per pool), never by adding a
lone drive. To grow, declare a 2nd pool in the args
(… /data http://minio2-{0...3}…/data).
Performance: 1 drive/pod on a node-local disk shared with the OS — fine for a lab, not for
real load.
Console behind a separate hostname: MINIO_BROWSER_REDIRECT_URL carries
https://minio-cluster-console.talos.lab.example.io in the manifest — the neutral domain of
the public repo, substituted by minio-cluster-up.sh from LAB_DOMAIN (lab.env) along with
the HTTPRoute hostnames. A direct kubectl apply keeps the example domain and breaks the
login redirects. See ../../README.md.
🧺minio-s3/cluster/ — MinIO distribué 4 nœuds (erasure coding) sur local-path
Variante résiliente du MinIO standalone (../) : un StatefulSet de 4 pods, 1 drive
(PVC local-path) par pod, 1 pod par worker. MinIO erasure-code les objets sur les 4
drives → le stockage objet survit à la perte de nœuds sans Longhorn, exactement comme
CloudNativePG assure lui-même la réplication de Postgres.
⚠️ Prérequis BLOQUANT : 4 workers Ready. Le défaut du dépôt (lab.env.example) est
WORKERS=3 → avec cette topologie l'addon ne peut pas démarrer du tout. Voir Prérequis.
C'est le S3 « pour de vrai » du lab : la cible des sauvegardes PostgreSQL
(../../cloudnative-pg/pg-backup-up.sh et pg-app-backup-cnpg-up.sh pointent
http://minio.minio-cluster.svc.cluster.local:9000), et la démo pédagogique de l'erasure
coding face au standalone.
Standalone (../)
Cluster (ici)
Workload
Deployment 1 replica
StatefulSet 4 pods
Drives
1 (local-path 10 Gi)
4 (1 PVC 10 Gi/pod, 1/worker)
Erasure coding
❌
✅ EC:2 (2 parités)
Résilience
aucune (perte du node = perte data)
tolère ~2 nœuds/drives down
Workers requis
1
4
Namespace
minio-s3
minio-cluster (coexistent)
Exposition (HTTPS via main-gateway, wildcard *.talos.lab.example.io) :
replicas: 4 + anti-affinité requiredDuringScheduling sur kubernetes.io/hostname (1 pod max par node) ; les control planes sont taintés NoSchedule donc ne comptent pas
kubectl get nodes -l '!node-role.kubernetes.io/control-plane'
les 4 PVC du volumeClaimTemplates ; le script s'arrête sans elle
kubectl get storageclass local-path
main-gateway + écouteur https + cert wildcard
les deux HTTPRoute
kubectl get gateway -n envoy-gateway-system
DNS minio-cluster + minio-cluster-console → 192.168.56.200
atteindre le VIP Envoy
getent hosts minio-cluster.talos.lab.example.io
⚠️ Passer WORKERS=4 (ou plus) dans lab.env avant de monter le cluster.lab.env.example livre WORKERS=3, et minio-cluster-up.sh ne fait qu'avertir (un
simple echo, pas un exit) s'il y a moins de 4 workers. Il applique quand même le
manifeste : le 4ᵉ pod reste Pending pour toujours (anti-affinité stricte, plus aucun node
éligible), donc le rollout status --timeout=300séchoue au bout de 5 minutes et le
déploiement démarre au mieux dégradé dès le premier jour (3 drives sur 4, zéro marge : une
panne de plus et l'erasure set perd son quorum d'écriture). Changer WORKERS demande un
vagrant destroy + remontée du cluster (cf. CLAUDE.md) : décide avant.
MinIO exige un minimum de 4 drives par erasure set, répartis uniformément entre les
nœuds. Avec 1 drive par pod (le pattern K8s propre sur local-path) :
3 nœuds × 1 drive = 3 drives → sous le minimum et non uniforme → refusé.
4 nœuds × 1 drive = 4 drives → 1 erasure set de 4, EC:2 → le minimum naturel.
3 nœuds ne redevient possible qu'avec ≥ 2 drives/nœud (3 × 2 = 6 drives = 1 set de 6),
c.-à-d. 2 PVC par pod : plus complexe, non retenu ici.
ℹ️ EC:2 sur 4 drives = 2 données + 2 parités. On peut perdre jusqu'à 2 drives/nœuds en
conservant la lecture ; l'écriture demande un quorum (≥ moitié + 1 des drives).
StatefulSet minio (podManagementPolicy: Parallel — les 4 pods démarrent ENSEMBLE et s'attendent)
minio-0 @ worker A ─ PVC data-minio-0 (local-path 10Gi) ┐
minio-1 @ worker B ─ PVC data-minio-1 ├─ 1 pool, 1 erasure set de 4, EC:2
minio-2 @ worker C ─ PVC data-minio-2 │
minio-3 @ worker D ─ PVC data-minio-3 ┘
▲ découverte des pairs via le Service HEADLESS minio-hl :
server http://minio-{0...3}.minio-hl.minio-cluster.svc.cluster.local:9000/data
Service minio (ClusterIP) ── équilibre sur les 4 pods ── HTTPRoutes (API + console)
Points clés du manifeste :
podManagementPolicy: Parallel (obligatoire) : en OrderedReady, minio-0 ne serait
jamais « ready » sans ses pairs → interblocage. En parallèle, les 4 bootent et forment le quorum.
Service headless minio-hl (clusterIP: None, publishNotReadyAddresses: true) : DNS
stable par pod, résolu avant que les pods soient ready.
Puis repointer les jobs de backup (MINIO_ENDPOINT / endpointURL) vers
http://minio.minio-cluster.svc.cluster.local:9000 — c'est déjà le cas des scripts de
../../cloudnative-pg/ — et décommissionner le standalone.
Moins de 4 workers = installation qui ne converge jamais : le 4ᵉ pod reste Pending
(anti-affinité required…), rollout status échoue après 5 min, et l'erasure set tourne
d'emblée à 3/4 drives. Le script n'échoue pas à ce stade (simple echo d'avertissement,
minio-cluster-up.sh) : ne pas conclure de son démarrage silencieux que la topologie est bonne.
minio-cluster-up.sh affiche l'utilisateur et le mot de passe root en clair sur stdout.
Les relire plutôt depuis le Secret (tableau Accès).
Les 4 × 10 Gi ne sont pas des quotas.local-path = dossier hostPath, la taille du PVC
n'est jamais appliquée. L'ephemeral-storage allocatable mesuré ici est de ~16,9 Go par
node (disque de 20 Go partagé avec l'OS et les images) : remplir les buckets provoque du
DiskPressure et l'éviction de pods sur les workers concernés — donc potentiellement la
perte simultanée de plusieurs drives MinIO. Surveiller :
Drives node-local : perdre un worker = perdre un drive. EC:2 encaisse 2 pertes, pas plus ;
un vagrant destroy en encaisse 4 (et les données avec).
Scaling : MinIO grossit par ajout de server pools (≥ 4 drives par pool), jamais par
ajout d'un drive isolé. Pour agrandir, déclarer un 2ᵉ pool dans les args
(… /data http://minio2-{0...3}…/data).
Perf : 1 drive/pod sur un disque node-local partagé avec l'OS — suffisant pour un lab,
pas pour de la charge réelle.
Console derrière un hostname distinct : MINIO_BROWSER_REDIRECT_URL porte
https://minio-cluster-console.talos.lab.example.io dans le manifeste — domaine neutre du
dépôt public, substitué par minio-cluster-up.sh depuis LAB_DOMAIN (lab.env) en même temps
que les hostnames des HTTPRoute. Un kubectl apply direct garde le domaine d'exemple et
casse les redirections de login. Cf. ../../LISEZ-MOI.md.
🔒vault-cluster/ — HashiCorp Vault HA (Raft), UI exposed over HTTPS
The lab's Vault server: 3 nodes on integrated Raft storage, 1 Longhorn PV per node,
UI + API over HTTPS at vault.talos.lab.example.io. Not to be confused with
../vault-secret-operator/, which is the client (it syncs Vault secrets into Kubernetes
Secret objects).
A central vault for everything the lab must not store in cleartext: application secrets (KV-v2),
PostgreSQL passwords rotated automatically (database engine), internal certificates (pki
engine). Consumers never talk to Vault directly — that is the VSO's job.
What gets set up here:
Component
Lab choice
Where it is decided
High availability
3 replicas, integrated Raft (no Consul), per-node anti-affinity
values.yaml (server.ha)
Storage
one 2Gi RWO Longhorn PVC per pod (data-vault-0/1/2)
values.yaml (server.dataStorage)
TLS
terminated by Envoy; Vault listens over HTTP internally (tls_disable)
values.yaml + httproute.yaml
disable_mlock
true — harmless on Talos (no swap) and avoids requiring IPC_LOCK
values.yaml (raft.config)
Agent Injector
disabled: we go through the VSO, not through sidecars
Installs the chart, initializes Vault, unseals the 3 pods and applies the HTTPRoute.
Idempotent: it only initializes if Vault is not already initialized, and only unseals the pods
that are actually sealed — so it is also the command to re-run after a reboot, which always
brings the pods back sealed (§🔐). VAULT_CHART_VERSION=… overrides the chart version.
🔐 The unseal keys and the root token land in _out/vault-init.json (mode 0600, and
_out/ is gitignored). The script never prints them. That file is the only copy: lose it
and Vault is unrecoverable — back it up outside the repo. See §🔐 below.
vault-up.sh already did this — the commands below are the manual equivalent, useful to
understand or to recover by hand. 5 unseal keys, threshold of 3.
⚠️ vault-1 and vault-2 cannot be unsealed straight away. With integrated Raft they
start uninitialized and only join through retry_join once the leader is unsealed;
unsealing them too early fails with 400 — Vault is not initialized. vault-up.sh waits for
initialized=true on each pod before unsealing it.
# Init on pod 0 — KEEP THE OUTPUT SOMEWHERE SAFE (keys + root token).
kubectl-nvaultexecvault-0--vaultoperatorinit\-key-shares=5-key-threshold=3-format=json>vault-init.json
# Unseal vault-0 (3 distinct keys): it becomes the leaderforiin012;dokubectl-nvaultexecvault-0--vaultoperatorunseal\"$(jq-r".unseal_keys_b64[$i]"vault-init.json)"done# vault-1 and vault-2 join the Raft (retry_join), then unseal in turnforpinvault-1vault-2;doforiin012;dokubectl-nvaultexec$p--vaultoperatorunseal\"$(jq-r".unseal_keys_b64[$i]"vault-init.json)"done;done# Root token:
jq-r.root_tokenvault-init.json
⚠️ vault-init.json holds the 5 unseal keys AND the root token. Never commit it —
vault-up.sh writes it to _out/vault-init.json precisely because _out/ is gitignored.
Every pod restart (chart upgrade, node down, Talos reboot) brings it back sealed:
re-run ./_k8s/vault-cluster/vault-up.sh (or unseal by hand with 3 of the 5 keys). A real deployment would use auto-unseal (the
Transit engine of another Vault, or a cloud KMS) — out of scope for the lab.
The UI and the API are exposed by the script's step [4/4]. To re-apply that route alone:
kubectlapply-f_k8s/vault-cluster/httproute.yaml
🌐 Domain: the manifest carries the neutral domain talos.lab.example.io (public repo) and
is not applied by any *-up.sh: edit the hostname, or substitute your own domain on the fly:
The VSO (../vault-secret-operator/) is already wired to
http://vault.vault.svc.cluster.local:8200 through the defaultVaultConnection from its
values.yaml. On the Vault side, what is left is enabling Kubernetes auth, the secrets engines,
the policies and the roles: it is all in ../vault-secret-operator/vault/README.md.
The HTTPRoute points at the vault-active service (the leader only): no 307 redirect
emitted by a standby. The certificate is the wildcard carried by the https listener of
main-gateway (annotation cert-manager.io/cluster-issuer, see
../envoy-gateway/Envoy-Proxy.yml). With the default LAB_ACME_ISSUER=staging it is not
publicly trusted: expect a browser warning, or use curl -k. Set LAB_ACME_ISSUER=prod for a
trusted cert — mind the 5 certificates/week cap.
⚠️ The UI is only reachable once Vault is unsealed.vault-active has no endpoint until a
leader is elected: the route then answers 503. After a cluster reboot you must unseal
every pod manually (3 keys out of 5) before the UI comes back.
longhorn (3 replicas) under Raft = 9 copies for 3 logical nodes.values.yaml:26 uses
the longhorn StorageClass, which replicates 3× at the block level — while Vault Raft
already replicates 3× at the application level. This is exactly the case
../longhorn/longhorn-r1-storageclass.yaml tells you to avoid ("replication already handled at
the application level"), and that CloudNativePG gets right with longhorn-r1. On the shared OS
disk (~20 GB/node) it feeds ReplicaSchedulingFailure. To fix it: set
server.dataStorage.storageClass to longhorn-r1. Careful, a PVC's StorageClass is
immutable: you have to delete the 3 data-vault-* PVCs, hence reinitialize Vault — do it
before you put anything in there.
Manual unsealing, after every reboot. No auto-unseal in this lab (see §🔐). A pod that
restarts is an unusable pod until 3 keys have been presented to it.
../chaos-kube/ excludes this namespace, and must keep doing so.
chaoskube deletes a random pod every hour; Raft survives the loss, but the pod comes back
sealed. Un-exclude vault and within a few hours all 3 are sealed and Vault is down —
recovery is vault-up.sh after every single kill.
Putting VAULT_ADDR / VAULT_TOKEN / VAULT_UNSEAL_KEY_* in lab.env does NOTHING. No
script in the repo reads those keys from lab.env — the only field picked out of that file is
CLOUDFLARE_API_TOKEN, by an explicit grep in ../platform-up.sh (line 30). The scripts in
../vault-secret-operator/vault/ only read the environment. To really load lab.env into
your shell:
set-a;../lab.env;set+a# exports everything the file defines
vaultstatus# must answer Sealed=false
lab.env is gitignored, but storing unseal keys in it makes it as sensitive as
vault-init.json: same precautions.
The data does not survive a PVC purge. It survives reboots (Longhorn partition), but a
helm uninstall followed by kubectl delete pvc destroys the Raft — and with it the whole
content of the vault, including everything the VSO references.
vault status returns "standby" on 2 pods out of 3: that is how HA Raft normally works (a
single leader). Write commands must target vault-active, not vault-0.
🔒vault-cluster/ — HashiCorp Vault HA (Raft), UI exposée en HTTPS
Le serveur Vault du lab : 3 nœuds en stockage Raft intégré, 1 PV Longhorn par nœud,
UI + API en HTTPS sous vault.talos.lab.example.io. À ne pas confondre avec
../vault-secret-operator/, qui est le client (il synchronise des secrets Vault en
Secret Kubernetes).
Un coffre-fort central pour tout ce que le lab ne doit pas stocker en clair : secrets applicatifs
(KV-v2), mots de passe PostgreSQL rotés automatiquement (moteur database), certificats internes
(moteur pki). Les consommateurs ne parlent jamais à Vault directement — c'est le rôle du VSO.
Ce qui est monté ici :
Brique
Choix du lab
Où c'est décidé
Haute dispo
3 réplicas, Raft intégré (pas de Consul), anti-affinité par node
values.yaml (server.ha)
Stockage
1 PVC Longhorn 2Gi RWO par pod (data-vault-0/1/2)
values.yaml (server.dataStorage)
TLS
terminé par Envoy ; Vault écoute en HTTP en interne (tls_disable)
values.yaml + httproute.yaml
disable_mlock
true — sans risque sur Talos (pas de swap) et évite d'exiger IPC_LOCK
values.yaml (raft.config)
Agent Injector
désactivé : on passe par le VSO, pas par des sidecars
Installe le chart, initialise Vault, descelle les 3 pods et applique l'HTTPRoute.
Idempotent : n'initialise que si Vault ne l'est pas, ne descelle que les pods réellement
scellés — c'est donc aussi la commande à relancer après un reboot, qui ramène toujours les
pods scellés (§🔐). VAULT_CHART_VERSION=… surcharge la version du chart.
🔐 Les clés de descellement et le token root atterrissent dans _out/vault-init.json
(mode 0600, et _out/ est gitignoré). Le script ne les affiche jamais. Ce fichier est le
seul exemplaire : le perdre rend Vault définitivement inaccessible — sauvegarde-le hors du
dépôt. Cf. §🔐 ci-dessous.
vault-up.sh l'a déjà fait — les commandes ci-dessous sont l'équivalent manuel, utile pour
comprendre ou pour rattraper à la main. 5 clés de descellement, seuil de 3.
⚠️ vault-1 et vault-2 ne se descellent pas tout de suite. Avec le Raft intégré ils
démarrent non initialisés et ne rejoignent le cluster que par retry_join, une fois le
leader descellé ; les desceller trop tôt échoue en 400 — Vault is not initialized.
vault-up.sh attend initialized=true sur chaque pod avant de le desceller.
# Init sur le pod 0 — GARDE LA SORTIE EN LIEU SÛR (clés + root token).
kubectl-nvaultexecvault-0--vaultoperatorinit\-key-shares=5-key-threshold=3-format=json>vault-init.json
# Descelle vault-0 (3 clés distinctes) : il devient leaderforiin012;dokubectl-nvaultexecvault-0--vaultoperatorunseal\"$(jq-r".unseal_keys_b64[$i]"vault-init.json)"done# vault-1 et vault-2 rejoignent le Raft (retry_join) puis se descellent à leur tourforpinvault-1vault-2;doforiin012;dokubectl-nvaultexec$p--vaultoperatorunseal\"$(jq-r".unseal_keys_b64[$i]"vault-init.json)"done;done# Root token :
jq-r.root_tokenvault-init.json
⚠️ vault-init.json contient les 5 clés de descellement ET le root token. Ne jamais le
committer — vault-up.sh l'écrit dans _out/vault-init.json justement parce que _out/ est
gitignoré. Chaque redémarrage de pod (upgrade du chart, node down, reboot Talos) le fait
revenir scellé : relancer ./_k8s/vault-cluster/vault-up.sh (ou desceller à la main avec
3 des 5 clés). Un vrai déploiement
utiliserait un auto-unseal (Transit d'un autre Vault, ou KMS cloud) — hors scope du lab.
L'UI et l'API sont exposées par l'étape [4/4] du script. Pour réappliquer cette route seule :
kubectlapply-f_k8s/vault-cluster/httproute.yaml
🌐 Domaine : le manifeste porte le domaine neutre talos.lab.example.io (dépôt public)
et n'est pas passé par un *-up.sh : édite le hostname, ou substitue ton domaine à la volée :
Le VSO (../vault-secret-operator/) est déjà câblé sur http://vault.vault.svc.cluster.local:8200
via le VaultConnection « default » de son values.yaml. Côté Vault, il reste à activer l'auth
Kubernetes, les moteurs de secrets, les policies et les roles : tout est dans
../vault-secret-operator/vault/LISEZ-MOI.md.
Le HTTPRoute pointe le service vault-active (le leader uniquement) : pas de redirection
307 émise par un standby. Le certificat est le wildcard porté par l'écouteur https de
main-gateway (annotation cert-manager.io/cluster-issuer, cf.
../envoy-gateway/Envoy-Proxy.yml). Avec le défaut LAB_ACME_ISSUER=staging, il n'est pas
publiquement reconnu : avertissement navigateur attendu, ou curl -k. Mets
LAB_ACME_ISSUER=prod pour un cert trusté — attention au plafond de 5 certificats/semaine.
⚠️ L'UI n'est accessible que si Vault est descellé.vault-active n'a aucun endpoint tant
qu'aucun leader n'est élu : la route répond alors 503. Après un reboot du cluster, il faut
redesceller manuellement chaque pod (3 clés sur 5) avant que l'UI revienne.
longhorn (3 réplicas) sous Raft = 9 copies pour 3 nœuds logiques.values.yaml:26 utilise
la StorageClass longhorn, qui réplique 3× au niveau bloc — alors que Vault Raft réplique
déjà 3× au niveau applicatif. C'est exactement le cas d'usage que
../longhorn/longhorn-r1-storageclass.yaml dit d'éviter (« réplication déjà faite au niveau
applicatif »), et que CloudNativePG traite correctement avec longhorn-r1. Sur le disque OS
partagé (~20 Go/node) ça alimente les ReplicaSchedulingFailure. Pour corriger : passer
server.dataStorage.storageClass à longhorn-r1. Attention, la StorageClass d'un PVC est
immuable : il faut supprimer les 3 PVC data-vault-*, donc réinitialiser Vault — à faire
avant de mettre quoi que ce soit dedans.
Descellement manuel, à chaque reboot. Pas d'auto-unseal dans ce lab (cf. §🔐). Un pod qui
redémarre est un pod inutilisable jusqu'à ce que 3 clés lui soient présentées.
../chaos-kube/ exclut ce namespace, et doit continuer.
chaoskube supprime un pod au hasard toutes les heures ; le Raft survit à la perte, mais le pod
revient scellé. Retire vault de l'exclusion et en quelques heures les 3 sont scellés et
Vault tombe — s'en sortir demande un vault-up.sh après chaque kill.
Mettre VAULT_ADDR / VAULT_TOKEN / VAULT_UNSEAL_KEY_* dans lab.env ne fait RIEN.
Aucun script du dépôt ne lit ces clés depuis lab.env — le seul champ pioché dans ce fichier est
CLOUDFLARE_API_TOKEN, par grep explicite dans ../platform-up.sh (ligne 30). Les scripts de
../vault-secret-operator/vault/ lisent uniquement l'environnement. Pour vraiment charger
lab.env dans ton shell :
set-a;../lab.env;set+a# exporte tout ce que le fichier définit
vaultstatus# doit répondre Sealed=false
lab.env est gitignoré, mais y stocker des clés de descellement le rend aussi sensible que
vault-init.json : même précaution.
Les données ne survivent pas à une purge des PVC. Elles survivent aux reboots (partition
Longhorn), mais un helm uninstall suivi d'un kubectl delete pvc détruit le Raft — donc tout
le contenu du coffre, y compris ce que le VSO référence.
vault status renvoie « standby » sur 2 pods sur 3 : c'est le fonctionnement normal du HA
Raft (un seul leader). Les commandes d'écriture doivent viser vault-active, pas vault-0.
🔑vault-secret-operator/ — Vault secrets synced into native K8s Secret objects
The Vault Secrets Operator (VSO), HashiCorp's official operator: it surfaces Vault secrets
as standard Kubernetes Secret objects, declaratively (CRDs, not scripts). An app consumes
a plain Secret (envFrom, valueFrom, volume) and never needs to talk to Vault. The
Vault server itself lives in ../vault-cluster/.
Three Vault ↔ Kubernetes integrations exist. This directory sets up the recommended one:
Integration
Model
2026 verdict
Vault Secrets Operator (VSO)
CRD → native K8s Secret, rotation + rollout
✅ recommended (this directory)
Vault CSI Provider
mounted volume, no K8s Secret created
fine if you refuse any secret in etcd
Agent Injector (sidecar)
annotations + one sidecar per pod
⚠️ legacy / maintenance
VSO wins because it is GitOps-friendly (CRDs are versionable, values are not), because it
covers static / dynamic / PKI with a single operator, because it detects drift (drift →
resync), because it renews the leases of dynamic creds and because it restarts the
workloads that cannot reload a Secret live.
The integration is wired on both sides: an identity proven on the K8s side must match an
authorized identity on the Vault side. Each subdirectory documents its own half.
The trust link is the K8s ServiceAccount: VSO presents its JWT token, Vault validates it
through the cluster's TokenReview API, and if the SA + the namespace + the audience match the
configured role, Vault returns a token carrying the policy — so precise read rights, and
nothing more.
Order matters: Vault first (the identity must exist before a client tries to log in), then
the operator, then the CRs.
# 1. Vault side: kubernetes auth + engines + policies + roles -> see vault/README.mdexportVAULT_ADDR=https://vault.talos.lab.example.io
exportVAULT_TOKEN=<root-token># see ../vault-cluster/README.md
./_k8s/vault-secret-operator/vault/talos-lab.sh
# 2. The operator (chart pinned to 1.5.0)
helmrepoaddhashicorphttps://helm.releases.hashicorp.com&&helmrepoupdate
helmupgrade--installvault-secrets-operatorhashicorp/vault-secrets-operator\--namespacevault-secrets-operator--create-namespace\--version1.5.0\-f_k8s/vault-secret-operator/values.yaml
kubectl-nvault-secrets-operatorrolloutstatusdeploy/vault-secrets-operator-controller-manager
# 3. The CRs on the cluster side -> see k8s/README.md
kubectlapply-f_k8s/vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml
Chart 1.5.0 = app version 1.5.0. No *-up.sh here: the two halves install separately,
and in that order.
creates oneVaultConnection named default on http://vault.vault.svc.cluster.local:8200: the CRs no longer have to repeat the address
defaultVaultConnection.skipTLSVerify
false
no TLS bypass (we speak HTTP internally, see ../vault-cluster/)
defaultAuthMethod.enabled
false
we prefer an explicit per-namespace VaultAuth (k8s/02-vaultauth.yaml): more readable, multi-tenant
clientCache.persistenceModel
none
cache in RAM, no dependency on the Transit engine. Enough for static secrets
clientCache.storageEncryption.enabled
false
no encrypted cache (follows from the previous point)
telemetry.serviceMonitor.enabled
false
switch to true once the Prometheus operator and its CRDs are installed (see ../observability/)
The day real dynamic creds get synced, switch back to
persistenceModel: direct-encrypted + storageEncryption.enabled: true (+ the Transit engine and
the vso-transit role, already prepared in vault/) — see the storageEncryption pitfall below.
kubectl-nvault-secrets-operatorgetpods
kubectl-nvault-secrets-operatorlogsdeploy/vault-secrets-operator-controller-manager-f
kubectlgetvaultconnection-A# the "default" created by values.yaml
kubectlgetvaultauth,vaultstaticsecret,vaultdynamicsecret,vaultpkisecret-A
The per-scenario checks live in k8s/README.md, the server-side ones in vault/README.md.
The concrete, tested path: a KV-v2 engine talos-lab/ with one subdirectory per app, and
an nginx demo that proves the whole loop.
./_k8s/vault-secret-operator/vault/talos-lab.sh# Vault config
kubectlapply-f_k8s/vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml
# Rotation, live: change the value in Vault…
vaultkvputtalos-lab/nginx-test-vault/config\APP_GREETING="Bonjour depuis Vault"APP_COLOR=greenAPP_SECRET_TOKEN=v2
# …VSO resyncs (refreshAfter 30s) -> Secret updated -> rolloutRestartTargets restarts the Deployment
kubectl-nnginx-test-vaultrolloutstatusdeploy/nginx-test-vault
Details of the objects created: k8s/README.md. Details of the Vault config: vault/README.md.
2. PostgreSQL password rotation by Vault (static role)#
Vault manages and rotates the password of an existing PostgreSQL user, and the consuming
workload is restarted automatically on every rotation. The PG server is the CloudNativePG
cluster pg-demo (see ../cloudnative-pg/).
ℹ️ Static role ≠ dynamic role. A dynamic role (<mount>/creds/<role>) has Vault
create an ephemeral user with a random name, revoked when the lease expires — the username
changes every time. A static role (<mount>/static-roles/<role>) takes over an
existing, fixed user and rotates only its password: the connection string stays stable.
It is that second mode that is set up here.
Vault (postgres admin) ──rotate password──► PG user "vault-rotate"
│ database/static-creds/vault-rotate (username + current password + ttl)
▼
VSO (VaultDynamicSecret allowStaticCreds) ──SecretTransformation──► K8s Secret pg-rotate-creds
│ DATABASE_URL + PGHOST/PGPORT/PGDATABASE/PGUSER/PGPASSWORD
▼
alpine Deployment (envFrom) ── rolloutRestartTargets ──► RESTARTED on every rotation
Scenario-specific prerequisites (in order):
⚠️ The PostgreSQL cluster must be UP. The whole loop depends on it: Vault connects to it as
admin to rotate the password, and the app connects to it with the creds. If pg-demo is missing
or stopped, writing database/config/… fails, rotations error out and the Secret is not
(re)generated.
kubectl-ncnpg-demogetclusterpg-demo# "Cluster in healthy state", 3/3 instances
# a. Superuser access (Vault connects as admin "postgres" to rotate the password)
kubectl-ncnpg-demopatchclusterpg-demo--type=merge\-p'{"spec":{"enableSuperuserAccess":true}}'# b. Database "vault" + user "vault-rotate", created once in PGPRIMARY=$(kubectl-ncnpg-demogetpods\-lcnpg.io/cluster=pg-demo,cnpg.io/instanceRole=primary-ojsonpath='{.items[0].metadata.name}')# bootstrap password: Vault replaces it right away
kubectl-ncnpg-demoexec"$PRIMARY"-cpostgres--psql-c\"CREATE ROLE \"vault-rotate\" WITH LOGIN PASSWORD 'bootstrap-temp-pw';"
kubectl-ncnpg-demoexec"$PRIMARY"-cpostgres--psql-c\"CREATE DATABASE vault OWNER \"vault-rotate\";"
Bringing it up:
exportVAULT_ADDR=http://127.0.0.1:8200# kubectl -n vault port-forward svc/vault-active 8200:8200exportVAULT_TOKEN=<root-token>
# ROTATION_PERIOD is tunable (default 3h; 2m to watch the loop live)
./_k8s/vault-secret-operator/vault/pg-dynamic-rotate.sh
kubectlapply-f_k8s/vault-secret-operator/k8s/pg-dynamic-rotate/pg-dynamic-rotate.yaml
Watching the rotation → the restart:
vaultreaddatabase/static-creds/vault-rotate# fixed username, password + ttl keep moving
vaultwrite-fdatabase/rotate-role/vault-rotate# force an immediate rotation
kubectl-npg-rotate-demogetdeploypg-rotate-demo-ojsonpath='{.metadata.generation}';echo
kubectl-npg-rotate-demogetpods-lapp=pg-rotate-demo-w
What the script writes into Vault: vault/README.md. The K8s objects: k8s/README.md.
⚠️ Every rotation = a rollout of the consumer. With ROTATION_PERIOD=2m, the alpine pod
restarts every 2 minutes; raise the period back up after the demo.
ℹ️ Security / lab: Vault connects as superuser postgres, the simplest option. In
production, prefer a dedicated admin role with reduced privileges (just enough to
ALTER ROLE … PASSWORD), and consider database/rotate-root so Vault also rotates its own
admin password.
How to authenticate (method, mount, role, SA, audience)
k8s/02-vaultauth.yaml
VaultAuthGlobal
VaultAuthshared across namespaces (DRY, multi-tenant)
k8s/03-vaultauthglobal.yaml
VaultStaticSecret
Syncs a KV secret (v1/v2) → Secret
k8s/10-static-kv.yaml
VaultDynamicSecret
Ephemeral creds (DB, cloud…) + lease renewal; also static roles
k8s/20-dynamic-db.yaml
VaultPKISecret
Issues and renews a TLS certificate (pki engine)
k8s/30-pki-tls.yaml
SecretTransformation
Templating: reshapes the data before the Secret is written
k8s/40-secrettransformation.yaml
HCPAuth / HCPVaultSecretsApp
HCP Vault Secrets (SaaS) variant — outside the lab
—
Least privilege, one policy at a time: one policy = one use (kv / db / pki), scoped to the
exact path. No mount wildcard. See vault/policies/.
One Vault role per app, bound to precise bound_service_account_names/_namespaces (never
*), with a dedicated audience (vault) and a short token_ttl (15m here): a stolen
token expires fast and is only good for Vault.
refreshAfter proportional to sensitivity (static) and renewalPercent (dynamic), to
renew before the lease expires.
rolloutRestartTargets everywhere: without it, a rotated secret never reaches the process.
GitOps: version the CRs, never the values. VSO writes the Secret objects, git never
sees them.
RBAC on VaultAuth: it is a door into Vault. In multi-tenant setups, VaultAuthGlobal +
allowedNamespaces frame who may use it.
storageEncryption.role sits at the wrong level in values.yaml (line 55). There it is a
sibling of method/mount/transitMount/keyName, while the vault-secrets-operator 1.5.0
chart expects it under controller.manager.clientCache.storageEncryption.**kubernetes**.role.
As it stands, the value is silently ignored and the role is "". No consequence today
(storageEncryption.enabled: false), but blocking as soon as the encrypted cache is turned on:
fix it before switching to direct-encrypted.
helmshowvalueshashicorp/vault-secrets-operator--version1.5.0# the reference structure
Login refused (permission denied / 403): the pod's SA or namespace does not match the
Vault role (bound_service_account_*), or the audience does not match
(VaultAuth.spec.kubernetes.audiences must be among the role's audience). That is 90% of the
cases — the dry-run login test in vault/README.md isolates it in a single command.
Vault cannot validate the JWT: auth/kubernetes/config is filled in wrong. Vault
in-cluster: its SA needs system:auth-delegator (the chart does it via
server.authDelegator.enabled=true). Vault external: kubernetes_host +
kubernetes_ca_cert + token_reviewer_jwt are all three mandatory. And watch out for the
vault/01-kubernetes-auth.sh bug documented in vault/README.md.
disable_iss_validation: true by default since Vault 1.9 — do not set it back to false
with short projected tokens (iss varies), or every login breaks.
Secret never updated inside the pod: rolloutRestartTargets is missing. The K8s Secret is
up to date (kubectl get secret proves it); it is the process that keeps the old value.
Orphaned dynamic creds after an operator crash: the client cache is in memory
(persistenceModel: none), so the leases are lost on restart and Vault keeps active creds that
nobody claims any more. Acceptable for static secrets, not for dynamic ones.
Vault TLS CA: over HTTPS with a private CA, give caCertSecretRef to the
VaultConnection, otherwise x509: certificate signed by unknown authority.
skipTLSVerify: true = lab only.
The k8s/20-dynamic-db.yaml demo is broken by a mount mismatch (db vs database):
details and fix in vault/README.md.
🔑vault-secret-operator/ — secrets Vault synchronisés en Secret K8s natifs
Le Vault Secrets Operator (VSO), opérateur officiel HashiCorp : il fait remonter des secrets
Vault dans des Secret Kubernetes standards, en déclaratif (des CRD, pas des scripts). Une
app consomme un Secret normal (envFrom, valueFrom, volume) et n'a jamais besoin de parler
à Vault. Le serveur Vault, lui, est dans ../vault-cluster/.
Trois intégrations Vault ↔ Kubernetes existent. Ce dossier monte celle qui est recommandée :
Intégration
Modèle
Verdict 2026
Vault Secrets Operator (VSO)
CRD → Secret K8s natif, rotation + rollout
✅ recommandé (ce dossier)
Vault CSI Provider
volume monté, aucun Secret K8s créé
ok si on refuse tout secret dans etcd
Agent Injector (sidecar)
annotations + un sidecar par pod
⚠️ legacy / maintenance
VSO gagne parce qu'il est GitOps-friendly (les CRD sont versionnables, les valeurs non), qu'il
couvre static / dynamic / PKI avec un seul opérateur, qu'il détecte la dérive (drift →
resync), qu'il renouvelle les leases des creds dynamiques et qu'il redémarre les workloads
incapables de recharger un Secret à chaud.
L'intégration se câble des deux côtés : une identité prouvée côté K8s doit correspondre à une
identité autorisée côté Vault. Chaque sous-dossier documente sa moitié.
Le maillon de confiance est le ServiceAccount K8s : VSO présente son token JWT, Vault le
valide via l'API TokenReview du cluster, et si le SA + le namespace + l'audience correspondent
au role configuré, Vault renvoie un token porteur de la policy — donc des droits de lecture
précis, et rien de plus.
pose unVaultConnection nommé default sur http://vault.vault.svc.cluster.local:8200 : les CR n'ont plus à répéter l'adresse
defaultVaultConnection.skipTLSVerify
false
pas de contournement TLS (on parle en HTTP en interne, cf. ../vault-cluster/)
defaultAuthMethod.enabled
false
on préfère un VaultAuth explicite par namespace (k8s/02-vaultauth.yaml) : plus lisible, multi-tenant
clientCache.persistenceModel
none
cache en RAM, aucune dépendance au moteur Transit. Suffisant pour du statique
clientCache.storageEncryption.enabled
false
pas de cache chiffré (découle du point précédent)
telemetry.serviceMonitor.enabled
false
à passer à true si l'opérateur Prometheus et ses CRD sont installés (cf. ../observability/)
Le jour où on synchronise de vrais creds dynamiques, repasser en
persistenceModel: direct-encrypted + storageEncryption.enabled: true (+ moteur Transit et role
vso-transit, déjà préparés dans vault/) — voir le piège sur storageEncryption plus bas.
kubectl-nvault-secrets-operatorgetpods
kubectl-nvault-secrets-operatorlogsdeploy/vault-secrets-operator-controller-manager-f
kubectlgetvaultconnection-A# le "default" posé par values.yaml
kubectlgetvaultauth,vaultstaticsecret,vaultdynamicsecret,vaultpkisecret-A
Les vérifications par scénario sont dans k8s/LISEZ-MOI.md, celles côté serveur dans
vault/LISEZ-MOI.md.
1. Secret KV du lab → variables d'env → redémarrage auto (nginx-test-vault)#
Le chemin concret et testé : un moteur KV-v2 talos-lab/ avec un sous-dossier par appli, et
une démo nginx qui prouve la boucle complète.
./_k8s/vault-secret-operator/vault/talos-lab.sh# config Vault
kubectlapply-f_k8s/vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml
# La rotation, en direct : on change la valeur dans Vault…
vaultkvputtalos-lab/nginx-test-vault/config\APP_GREETING="Bonjour depuis Vault"APP_COLOR=greenAPP_SECRET_TOKEN=v2
# …VSO resync (refreshAfter 30s) -> Secret mis à jour -> rolloutRestartTargets relance le Deployment
kubectl-nnginx-test-vaultrolloutstatusdeploy/nginx-test-vault
Détail des objets créés : k8s/LISEZ-MOI.md. Détail de la config Vault : vault/LISEZ-MOI.md.
2. Rotation du mot de passe PostgreSQL par Vault (static role)#
Vault gère et fait tourner le mot de passe d'un utilisateur PostgreSQL existant, et le workload
consommateur est redémarré automatiquement à chaque rotation. Le serveur PG est le cluster
CloudNativePG pg-demo (cf. ../cloudnative-pg/).
ℹ️ Static role ≠ dynamic role. Un dynamic role (<mount>/creds/<role>) fait créer par
Vault un user éphémère au nom aléatoire, révoqué à l'expiration du lease — le username change à
chaque fois. Un static role (<mount>/static-roles/<role>) prend en gestion un user
existant et fixe et n'en rotate que le mot de passe : la chaîne de connexion reste stable.
C'est ce second mode qui est monté ici.
⚠️ Le cluster PostgreSQL doit être UP. Toute la boucle en dépend : Vault s'y connecte en admin
pour rotater le mot de passe, et l'app s'y connecte avec les creds. Si pg-demo est absent ou
arrêté, l'écriture de database/config/… échoue, les rotations tombent en erreur et le Secret
n'est pas (re)généré.
kubectl-ncnpg-demogetclusterpg-demo# "Cluster in healthy state", 3/3 instances
# a. Accès superuser (Vault se connecte en admin "postgres" pour rotater le password)
kubectl-ncnpg-demopatchclusterpg-demo--type=merge\-p'{"spec":{"enableSuperuserAccess":true}}'# b. Base "vault" + user "vault-rotate" créés une fois dans PGPRIMARY=$(kubectl-ncnpg-demogetpods\-lcnpg.io/cluster=pg-demo,cnpg.io/instanceRole=primary-ojsonpath='{.items[0].metadata.name}')# mot de passe bootstrap : il sera immédiatement remplacé par Vault
kubectl-ncnpg-demoexec"$PRIMARY"-cpostgres--psql-c\"CREATE ROLE \"vault-rotate\" WITH LOGIN PASSWORD 'bootstrap-temp-pw';"
kubectl-ncnpg-demoexec"$PRIMARY"-cpostgres--psql-c\"CREATE DATABASE vault OWNER \"vault-rotate\";"
Mise en route :
exportVAULT_ADDR=http://127.0.0.1:8200# kubectl -n vault port-forward svc/vault-active 8200:8200exportVAULT_TOKEN=<root-token>
# ROTATION_PERIOD réglable (défaut 3h ; 2m pour observer la boucle en direct)
./_k8s/vault-secret-operator/vault/pg-dynamic-rotate.sh
kubectlapply-f_k8s/vault-secret-operator/k8s/pg-dynamic-rotate/pg-dynamic-rotate.yaml
Observer la rotation → le redémarrage :
vaultreaddatabase/static-creds/vault-rotate# username fixe, password + ttl qui bougent
vaultwrite-fdatabase/rotate-role/vault-rotate# forcer une rotation immédiate
kubectl-npg-rotate-demogetdeploypg-rotate-demo-ojsonpath='{.metadata.generation}';echo
kubectl-npg-rotate-demogetpods-lapp=pg-rotate-demo-w
Ce que le script écrit dans Vault : vault/LISEZ-MOI.md. Les objets K8s : k8s/LISEZ-MOI.md.
⚠️ Chaque rotation = un rollout du consommateur. Avec ROTATION_PERIOD=2m, le pod alpine
redémarre toutes les 2 minutes ; remonter la période après la démo.
ℹ️ Sécurité / lab : Vault se connecte en superuser postgres, le plus simple. En prod,
préférer un role d'admin dédié à privilèges réduits (juste de quoi ALTER ROLE … PASSWORD), et
envisager database/rotate-root pour que Vault rotate aussi son propre mot de passe admin.
Comment s'authentifier (méthode, mount, role, SA, audience)
k8s/02-vaultauth.yaml
VaultAuthGlobal
VaultAuthmutualisé entre namespaces (DRY, multi-tenant)
k8s/03-vaultauthglobal.yaml
VaultStaticSecret
Synchronise un secret KV (v1/v2) → Secret
k8s/10-static-kv.yaml
VaultDynamicSecret
Creds éphémères (DB, cloud…) + renouvellement de lease ; aussi les static roles
k8s/20-dynamic-db.yaml
VaultPKISecret
Émet et renouvelle un certificat TLS (moteur pki)
k8s/30-pki-tls.yaml
SecretTransformation
Templating : reformate les données avant écriture du Secret
k8s/40-secrettransformation.yaml
HCPAuth / HCPVaultSecretsApp
Variante HCP Vault Secrets (SaaS) — hors lab
—
Moindre privilège par policy : une policy = un usage (kv / db / pki), scopée au chemin exact.
Aucun wildcard de mount. Cf. vault/policies/.
Un role Vault par app, bindé à des bound_service_account_names/_namespaces précis (jamais
*), avec une audience dédiée (vault) et un token_ttl court (15m ici) : un token volé
expire vite et ne vaut que pour Vault.
refreshAfter proportionnel à la sensibilité (static) et renewalPercent (dynamic), pour
renouveler avant expiration du lease.
rolloutRestartTargets systématique : sans lui, un secret roté n'atteint jamais le process.
GitOps : versionner les CR, jamais les valeurs. VSO écrit les Secret, git ne les voit
pas.
RBAC sur les VaultAuth : c'est une porte d'entrée vers Vault. En multi-tenant,
VaultAuthGlobal + allowedNamespaces cadrent qui peut s'en servir.
storageEncryption.role est au mauvais niveau dans values.yaml (ligne 55). Il y est frère
de method/mount/transitMount/keyName, alors que le chart vault-secrets-operator 1.5.0
l'attend sous controller.manager.clientCache.storageEncryption.**kubernetes**.role. Tel quel, la
valeur est ignorée en silence et le role vaut "". Sans conséquence aujourd'hui
(storageEncryption.enabled: false), mais bloquant dès qu'on active le cache chiffré : à corriger
avant de passer en direct-encrypted.
helmshowvalueshashicorp/vault-secrets-operator--version1.5.0# la structure de référence
Login refusé (permission denied / 403) : le SA ou le namespace du pod ne correspond pas au
role Vault (bound_service_account_*), ou l'audience ne matche pas
(VaultAuth.spec.kubernetes.audiences doit être dans les audience du role). C'est 90 % des
cas — le test de login « à blanc » de vault/LISEZ-MOI.md l'isole en une commande.
Vault ne peut pas valider le JWT : auth/kubernetes/config mal renseigné. Vault
in-cluster : son SA doit avoir system:auth-delegator (le chart le fait via
server.authDelegator.enabled=true). Vault externe : kubernetes_host +
kubernetes_ca_cert + token_reviewer_jwt sont tous les trois obligatoires. Et attention au bug
de vault/01-kubernetes-auth.sh documenté dans vault/LISEZ-MOI.md.
disable_iss_validation : true par défaut depuis Vault 1.9 — ne pas le repasser à false
avec des tokens projetés courts (l'iss varie), sinon tous les logins cassent.
Secret jamais mis à jour dans le pod : il manque rolloutRestartTargets. Le Secret K8s est
bien à jour (kubectl get secret le prouve) ; c'est le process qui garde l'ancienne valeur.
Creds dynamiques orphelins après un crash de l'opérateur : le cache client est en mémoire
(persistenceModel: none), donc les leases sont perdus au redémarrage et Vault garde des creds
actifs que plus personne ne réclame. Acceptable pour du statique, pas pour du dynamique.
CA TLS de Vault : en HTTPS avec une CA privée, fournir caCertSecretRef au VaultConnection,
sinon x509: certificate signed by unknown authority. skipTLSVerify: true = lab uniquement.
La démo k8s/20-dynamic-db.yaml est cassée par un décalage de mount (db vs database) :
détail et correctif dans vault/LISEZ-MOI.md.
The cluster half of the VSO wiring: the declarative resources the operator watches in order to
produce Kubernetes Secret objects. It is all kubectl from here. The server-side counterpart
(auth, engines, policies, roles) lives in ../vault/.
Vault*Secret ──spec.vaultAuthRef──► VaultAuth ──► VaultConnection (or the "default" from values.yaml)
│
└─► Vault role ──► Vault policy
The VaultAuth carries the Vault role; the role carries the policy. One broken link (role
name, SA, namespace, audience, mount) gives SecretSynced: false in the CR's events.
Path B — the numbered teaching CRs (namespace demo)#
kubectlapply-f00-namespace-rbac.yaml# ns "demo" + ServiceAccount "vso-app"
kubectlapply-f01-vaultconnection.yaml# optional if defaultVaultConnection is on
kubectlapply-f02-vaultauth.yaml# 3 VaultAuth: static / dynamic / pki# 03 = multi-tenant variant (VaultAuthGlobal), INSTEAD OF 02
kubectlapply-f10-static-kv.yaml# KV-v2 -> Secret "static-kv"
kubectlapply-f20-dynamic-db.yaml# ephemeral DB creds (see ⚠️ Pitfalls: broken mount)
kubectlapply-f30-pki-tls.yaml# TLS certificate -> Secret "pki-tls"
kubectlapply-f40-secrettransformation.yaml# templating -> Secret "app-env"
kubectlapply-f50-demo-deployment.yaml# app consuming the 3 Secrets + taking the rollouts
🌐 Domain: 30-pki-tls.yaml requests the CN demo-app.talos.lab.example.io (the public
repo's neutral domain). It must stay inside the allowed_domains of the PKI role, itself
derived from LAB_DOMAIN by ../vault/00-secrets-engines.sh. If you have your own domain:
sed 's/talos\.lab\.example\.io/talos.lab.my-domain.tld/g' 30-pki-tls.yaml | kubectl apply -f -
(see ../../README.md).
the app-env transformation (DATABASE_URL, APP_PASSWORD, excludeRaw: true) + the static-kv-templated CR that uses it
50-demo-deployment.yaml
Deployment
busybox:1.36: envFrom on static-kv, env key by key on dynamic-db, volume mounted from pki-tls
nginx-test-vault/ — lab KV secret → env vars → rollout#
The complete loop, and the easiest one to watch. Objects created (all in the
nginx-test-vault ns):
Object
Detail
Namespace + ServiceAccount nginx-test-vault
the identity the Vault role of the same name expects
VaultAuth nginx-test-vault
mount kubernetes, role nginx-test-vault, audiences: [vault]
VaultStaticSecret nginx-test-vault-config
type: kv-v2, mount: talos-lab, path: nginx-test-vault/config, refreshAfter: 30s, hmacSecretData: true (detects drift without logging the values), rolloutRestartTargets → the Deployment
Deployment nginx-test-vault
nginx:1.27-alpine, 2 replicas, envFrom on the Secret → APP_GREETING / APP_COLOR / APP_SECRET_TOKEN
pg-dynamic-rotate/ — PostgreSQL password rotated by Vault#
Static role: the username stays fixed, only the password changes; the consumer is restarted on
every rotation. The matching Vault config is detailed in ../vault/README.md, the full scenario
(PostgreSQL prerequisites included) in ../README.md.
Object
Detail
Namespace + ServiceAccount pg-rotate
identity bound to the Vault role pg-rotate-demo
VaultAuth pg-rotate
mount kubernetes, role pg-rotate-demo, audiences: [vault]
# Path B (ns demo)
kubectl-ndemogetvaultauth,vaultstaticsecret,vaultdynamicsecret,vaultpkisecret
kubectl-ndemodescribevaultstaticsecretstatic-kv# events: "Secret synced"
kubectl-ndemogetsecret# static-kv, dynamic-db, pki-tls, app-env
kubectl-ndemogetsecretstatic-kv-ojsonpath='{.data.password}'|base64-d;echo
kubectl-ndemologsdeploy/demo-app# the injected DB_/APP_ variables# Path A — nginx: the secret -> env -> rollout loop
kubectl-nnginx-test-vaultgetvaultstaticsecretnginx-test-vault-config# SecretSynced=TruePOD=$(kubectl-nnginx-test-vaultgetpod-lapp=nginx-test-vault-ojsonpath='{.items[0].metadata.name}')
kubectl-nnginx-test-vaultexec"$POD"--env|grep'^APP_'# Path A — PostgreSQL: the rendered DSN + the proof of the restart
kubectl-npg-rotate-demogetsecretpg-rotate-creds-ojsonpath='{.data.DATABASE_URL}'|base64-d;echo
kubectl-npg-rotate-demogetdeploypg-rotate-demo-ojsonpath='{.metadata.generation}';echo# On a sync problem, the source of truth remains the operator logs:
kubectl-nvault-secrets-operatorlogsdeploy/vault-secrets-operator-controller-manager-f
20-dynamic-db.yaml cannot sync as it stands. It asks for mount: db, but
../vault/00-secrets-engines.sh:19 mounts the database engine on database/ (no
-path=db), and the vso-dynamic-db.hcl policy only allows db/creds/demo-app. Result: a
systematic 403/404. Fix on the Vault side (vault secrets enable -path=db database) and full
explanation in ../vault/README.md. The database engine also needs a connection and a
creds/demo-app role pointing at a real database — both left commented out in the script.
50-demo-deployment.yaml will not start if one of the 3 Secrets is missing. No reference is
marked optional: true: without dynamic-db (see the previous pitfall) the pod stays in
CreateContainerConfigError, and without pki-tls it stays stuck in ContainerCreating (volume
not found). The diagnosis is in kubectl -n demo describe pod, not in the logs.
02 and 03 are two alternatives, not two steps. Applying both creates two VaultAuth for
the same role — pointless, and a source of confusion about which one a CR actually uses.
SecretSynced: false: read the CR's event (kubectl describe). In practice it is either a
refused login (role / SA / namespace / audience — see ../vault/README.md), or a wrong
mount/path.
The Secret changes but the pod does not: rolloutRestartTargets is missing. The K8s
Secret is up to date (kubectl get secret proves it), but the process keeps the old value in
memory.
VaultPKISecret refused: commonName outside the allowed_domains of the PKI role, or a
requested ttl larger than the role's max_ttl (72h here).
excludes + transformationRefs: without excludes: [".*"] (or excludeRaw: true), the
rendered Secret also contains Vault's raw keys (username, password, ttl…). Handy for
debugging, best avoided when you want a clean Secret.
La moitié cluster du câblage VSO : les ressources déclaratives que l'opérateur surveille pour
produire des Secret Kubernetes. Tout se joue au kubectl. Le pendant côté serveur (auth,
moteurs, policies, roles) est dans ../vault/.
Vault*Secret ──spec.vaultAuthRef──► VaultAuth ──► VaultConnection (ou le « default » de values.yaml)
│
└─► role Vault ──► policy Vault
Le VaultAuth porte le role Vault ; le role porte la policy. Un maillon cassé (nom de
role, SA, namespace, audience, mount) donne SecretSynced: false dans les events du CR.
Parcours B — les CR pédagogiques numérotés (namespace demo)#
kubectlapply-f00-namespace-rbac.yaml# ns "demo" + ServiceAccount "vso-app"
kubectlapply-f01-vaultconnection.yaml# optionnel si defaultVaultConnection est actif
kubectlapply-f02-vaultauth.yaml# 3 VaultAuth : static / dynamic / pki# 03 = variante multi-tenant (VaultAuthGlobal), À LA PLACE de 02
kubectlapply-f10-static-kv.yaml# KV-v2 -> Secret "static-kv"
kubectlapply-f20-dynamic-db.yaml# creds DB éphémères (voir ⚠️ Pièges : mount cassé)
kubectlapply-f30-pki-tls.yaml# certificat TLS -> Secret "pki-tls"
kubectlapply-f40-secrettransformation.yaml# templating -> Secret "app-env"
kubectlapply-f50-demo-deployment.yaml# app qui consomme les 3 Secret + reçoit les rollouts
🌐 Domaine : 30-pki-tls.yaml demande un CN demo-app.talos.lab.example.io (domaine
neutre du dépôt public). Il doit rester dans l'allowed_domains du role PKI, lui-même
posé depuis LAB_DOMAIN par ../vault/00-secrets-engines.sh. Si tu as ton propre domaine :
sed 's/talos\.lab\.example\.io/talos.lab.mon-domaine.tld/g' 30-pki-tls.yaml | kubectl apply -f -
(cf. ../../LISEZ-MOI.md).
La boucle complète, la plus simple à observer. Objets créés (tous dans le ns
nginx-test-vault) :
Objet
Détail
Namespace + ServiceAccount nginx-test-vault
l'identité attendue par le role Vault du même nom
VaultAuth nginx-test-vault
mount kubernetes, role nginx-test-vault, audiences: [vault]
VaultStaticSecret nginx-test-vault-config
type: kv-v2, mount: talos-lab, path: nginx-test-vault/config, refreshAfter: 30s, hmacSecretData: true (détecte la dérive sans logguer les valeurs), rolloutRestartTargets → le Deployment
Deployment nginx-test-vault
nginx:1.27-alpine, 2 réplicas, envFrom sur le Secret → APP_GREETING / APP_COLOR / APP_SECRET_TOKEN
pg-dynamic-rotate/ — mot de passe PostgreSQL roté par Vault#
Static role : le username reste fixe, seul le mot de passe change ; le consommateur est
relancé à chaque rotation. La config Vault correspondante est détaillée dans ../vault/LISEZ-MOI.md,
le scénario complet (prérequis PostgreSQL inclus) dans ../LISEZ-MOI.md.
Objet
Détail
Namespace + ServiceAccount pg-rotate
identité bindée au role Vault pg-rotate-demo
VaultAuth pg-rotate
mount kubernetes, role pg-rotate-demo, audiences: [vault]
mount: database, path: static-creds/vault-rotate, allowStaticCreds: true ; excludes: [".*"] pour ne garder que les clés templatées → Secret pg-rotate-creds ; rolloutRestartTargets → le Deployment
Deployment pg-rotate-demo
alpine:3.20, envFrom sur pg-rotate-creds : la DSN arrive dans ses variables d'env
# Parcours B (ns demo)
kubectl-ndemogetvaultauth,vaultstaticsecret,vaultdynamicsecret,vaultpkisecret
kubectl-ndemodescribevaultstaticsecretstatic-kv# events : "Secret synced"
kubectl-ndemogetsecret# static-kv, dynamic-db, pki-tls, app-env
kubectl-ndemogetsecretstatic-kv-ojsonpath='{.data.password}'|base64-d;echo
kubectl-ndemologsdeploy/demo-app# les variables DB_/APP_ injectées# Parcours A — nginx : la boucle secret -> env -> rollout
kubectl-nnginx-test-vaultgetvaultstaticsecretnginx-test-vault-config# SecretSynced=TruePOD=$(kubectl-nnginx-test-vaultgetpod-lapp=nginx-test-vault-ojsonpath='{.items[0].metadata.name}')
kubectl-nnginx-test-vaultexec"$POD"--env|grep'^APP_'# Parcours A — PostgreSQL : la DSN rendue + la preuve du redémarrage
kubectl-npg-rotate-demogetsecretpg-rotate-creds-ojsonpath='{.data.DATABASE_URL}'|base64-d;echo
kubectl-npg-rotate-demogetdeploypg-rotate-demo-ojsonpath='{.metadata.generation}';echo# Sur un problème de synchro, la source de vérité reste les logs de l'opérateur :
kubectl-nvault-secrets-operatorlogsdeploy/vault-secrets-operator-controller-manager-f
20-dynamic-db.yaml ne peut pas se synchroniser en l'état. Il demande mount: db, mais
../vault/00-secrets-engines.sh:19 monte le moteur database sur database/ (pas de
-path=db), et la policy vso-dynamic-db.hcl n'autorise que db/creds/demo-app. Résultat :
403/404 systématique. Correction côté Vault (vault secrets enable -path=db database) et
explication complète dans ../vault/LISEZ-MOI.md. Le moteur database a aussi besoin d'une
connexion et d'un role creds/demo-app pointant une vraie base — laissés en commentaire dans le
script.
50-demo-deployment.yaml ne démarre pas si un des 3 Secret manque. Aucune référence n'est
marquée optional: true : sans dynamic-db (cf. piège précédent) le pod reste en
CreateContainerConfigError, et sans pki-tls il reste bloqué en ContainerCreating (volume
introuvable). Le diagnostic est dans kubectl -n demo describe pod, pas dans les logs.
02 et 03 sont deux alternatives, pas deux étapes. Appliquer les deux crée deux VaultAuth
pour le même role — inutile, et source de confusion sur lequel un CR utilise réellement.
SecretSynced: false : lire l'event du CR (kubectl describe). En pratique soit un login
refusé (role / SA / namespace / audience — cf. ../vault/LISEZ-MOI.md), soit un mount/path faux.
Le Secret change mais pas le pod : il manque rolloutRestartTargets. Le Secret K8s est
bien à jour (kubectl get secret le prouve), mais le process garde l'ancienne valeur en mémoire.
VaultPKISecret refusé : commonName hors des allowed_domains du role PKI, ou ttl
demandé supérieur au max_ttl du role (72h ici).
excludes + transformationRefs : sans excludes: [".*"] (ou excludeRaw: true), le Secret
rendu contient en plus les clés brutes de Vault (username, password, ttl…). Pratique
pour déboguer, à éviter quand on veut un Secret propre.
The server half of the VSO wiring. These scripts create everything the VSO is going to
consume: the Kubernetes auth method, the secrets engines, the policies (least privilege) and
the roles that tie a K8s identity to a policy. The cluster-side counterpart is in ../k8s/.
VSO presents the JWT token of the app's ServiceAccount. Vault validates it through the
cluster's TokenReview API, then checks that it matches a role
(bound_service_account_names + _namespaces + audience). If it does, Vault returns a token
carrying the role's policies — so precise read rights, and nothing else.
SA JWT (audience "vault") ─► auth/kubernetes/config (TokenReview) ─► role ─► policy
│
kvv2/ · database/ · pki/ · transit/ ◄─────────────┘
Break a single link (SA name, namespace, audience, policy path, mount name) and you get a 403 on
the VSO side and a SecretSynced: false on the CR. That is the overwhelming majority of failures.
exportVAULT_ADDR="https://vault.talos.lab.example.io"# or http://127.0.0.1:8200 via port-forwardexportVAULT_TOKEN="<root-token>"# see ../../vault-cluster/README.md
vaultstatus# must answer Sealed=false
Port-forward if Vault is not exposed:
kubectl -n vault port-forward svc/vault-active 8200:8200.
⚠️ VAULT_ADDR/VAULT_TOKEN set in lab.env have NO effect: no script reads that file. You
have to export them (or set -a; . ./lab.env; set +a). Details and precautions in
../../vault-cluster/README.md (Pitfalls section).
This is the path that actually runs: KV-v2 mount talos-lab/ (one subdirectory per app) and
rotation of a PostgreSQL password through a static role. The matching K8s demos are
../k8s/nginx-test-vault/ and ../k8s/pg-dynamic-rotate/.
Path B — the teaching demo (mounts kvv2/, pki/, transit/)#
cd_k8s/vault-secret-operator/vault
bash00-secrets-engines.sh# engines + a demo secret + PKI CA + transit keyMODE=inclusterbash01-kubernetes-auth.sh# auth/kubernetes (see the pitfall below!)
bash02-roles.sh# policies + roles vso-static/-dynamic/-pki/-transit
It serves as reading material for the numbered CRs in ../k8s/ (10-, 20-, 30-, 40-). Two
of its three scripts have flaws documented under ⚠️ Pitfalls — read them first.
The connection and the role of the database engine are left commented out in the script (they
depend on your database). Path A, on the other hand, writes them for real
(pg-dynamic-rotate.sh).
Two modes, depending on where Vault runs. This is the step that blocks people the most.
MODE=incluster (our case) — Vault calls TokenReview with the token of its own pod: its
ServiceAccount must carry the system:auth-delegator ClusterRole, which the hashicorp/vault
chart does by default (server.authDelegator.enabled=true). The config then boils down to
kubernetes_host; token_reviewer_jwt and kubernetes_ca_cert stay empty (Vault uses the CA
mounted in its container). disable_iss_validation stays true (the default since Vault 1.9).
MODE=external — Vault sits outside the cluster and can infer nothing. You have to give it:
a delegatorServiceAccount on the K8s side (system:auth-delegator);
its long-lived token (token_reviewer_jwt), with which Vault will validate the apps' JWTs;
the API endpoint (KUBE_HOST, by default the lab's VIP https://192.168.56.5:6443,
see talos/patch-cp.yaml) and the CA of that API (SA_CA_CRT).
Loads the 4 files from policies/, then creates 4 auth/kubernetes roles. All with
token_ttl=15m and audience=vault (which must match VaultAuth.spec.kubernetes.audiences
on the K8s side).
Kubernetes auth (kubernetes_host=https://kubernetes.default.svc), KV-v2 engine
talos-lab/, secret talos-lab/nginx-test-vault/config (3 APP_* keys), policy
talos-lab-nginx-test-vault (read on talos-lab/data|metadata/nginx-test-vault/* only) and role
nginx-test-vault bound to the SA/ns nginx-test-vault.
Adding an app = a talos-lab/<app>/… subdirectory, a policy scoped to that subdirectory, a role
dedicated to the app's SA/ns. This script is the template to copy.
Vault takes over a fixed PG user (vault-rotate) and rotates its password only (static
role) — the app's connection string stays stable. Scenario overview and PostgreSQL prerequisites:
../README.md.
vaultreaddatabase/static-creds/vault-rotate# username (fixed) + current password + remaining ttl
vaultwrite-fdatabase/rotate-role/vault-rotate# force an immediate rotation
vaultstatus# Sealed=false
vaultsecretslist# kvv2/, database/, pki/, transit/, talos-lab/
vaultauthlist# kubernetes/ present
vaultreadauth/kubernetes/config# kubernetes_host MUST be a real URL
vaultlistauth/kubernetes/role# vso-*, nginx-test-vault, pg-rotate-demo
vaultpolicylist
vaultkvgetkvv2/demo/app# demo secret (path B)
vaultkvgettalos-lab/nginx-test-vault/config# lab secret (path A)
vaultlistpki/issuers# exactly ONE CA expected (see Pitfalls)# Dry-run login test, without going through VSO — isolates identity problems:JWT=$(kubectl-ndemocreatetokenvso-app--audience=vault)
vaultwriteauth/kubernetes/loginrole=vso-staticjwt="$JWT"# must return a token + its policy
The "dynamic DB" demo cannot work as it stands — mount mismatch.00-secrets-engines.sh:19 runs vault secrets enable database, without -path: the engine
is therefore mounted on database/. But ../k8s/20-dynamic-db.yaml:11 asks for mount: db
and policies/vso-dynamic-db.hcl:5 only allows db/creds/demo-app. The VaultDynamicSecret
can only return a 403 or a 404, whatever else you do. To line everything up on db/, mount
the engine in the right place:
(Path A is not affected: pg-dynamic-rotate.sh writes and reads database/ consistently.)
01-kubernetes-auth.sh in incluster mode breaks every login. Line 26 writes
kubernetes_host="https://\$KUBERNETES_PORT_443_TCP_ADDR:443": the \$ is a literal$ in
bash, and Vault performs no environment substitution on config values. Vault therefore stores
the string as-is, and any authentication through auth/kubernetes fails.
The sibling script gets it right with kubernetes_host="https://kubernetes.default.svc"
(talos-lab.sh:22). Diagnosis:
vaultreadauth/kubernetes/config# if kubernetes_host contains a "$", this is the bug
vaultwriteauth/kubernetes/configkubernetes_host="https://kubernetes.default.svc"# fix
The 00-/01-/02- scripts are NOT idempotent, contrary to what their header claims.
Three distinct reasons:
00-secrets-engines.sh:16 (vault kv put kvv2/demo/app …) and talos-lab.sh:31-34
(APP_SECRET_TOKEN) overwrite the value and create a new KV version on every run. If
you rotated the secret to watch the VSO resync, re-running the script silently puts the
original value back.
00-secrets-engines.sh:35-36: since Vault 1.11 (multi-issuers), pki/root/generate/internalno longer fails on an already configured mount — it simply adds a new issuer.
The || echo "(CA racine déjà générée)" guard therefore never fires, and every run stacks
one more root CA. Check with vault list pki/issuers and delete the duplicates
(vault delete pki/issuer/<id>).
pg-dynamic-rotate.sh rewrites database/config/pg-demo and the static role on every pass.
That is the intended mechanism for changing ROTATION_PERIOD, but it is not a no-op.
No environment guard in 00-/01-/02-, unlike talos-lab.sh:14-15 and
pg-dynamic-rotate.sh:21-22, which refuse to start without VAULT_ADDR/VAULT_TOKEN. Without
VAULT_ADDR, the CLI silently hits https://127.0.0.1:8200; the script only stops at line 38 of
00- (${VAULT_ADDR} under set -u → unbound variable), so after it has already tried to
write. Export both variables before running anything.
00-secrets-engines.sh:11 hides every Vault error. The helper
enable() { vault secrets enable "$@" 2>/dev/null || echo " (déjà activé : $*)"; } swallows
stderr: a Vault is sealed, a permission denied or a connection refused all show up as
"(déjà activé)". The script will indeed stop at the next command (set -e), but with a
misleading diagnosis. When in doubt, re-run the command by hand without 2>/dev/null.
permission denied at login: the pod's SA/namespace does not match
bound_service_account_names/_namespaces, or the JWT's audience ≠ the role's audience.
The dry-run login test in the ✅ section isolates the problem without involving VSO.
error validating token: … 403: the reviewer lacks system:auth-delegator (in-cluster), or
the token_reviewer_jwt is wrong/expired (external).
Do not set disable_iss_validation=false again: the default has been true since Vault 1.9,
and the iss of projected tokens varies → broken logins.
Demo secrets in cleartext in the scripts.00-secrets-engines.sh and talos-lab.sh write
dummy values, versioned in git. That is accepted for a lab; in production, values are injected
outside git (vault kv put … @- from a pipe, or a real provisioning pipeline).
La moitié serveur du câblage VSO. Ces scripts créent tout ce que le VSO va consommer :
la méthode d'auth Kubernetes, les moteurs de secrets, les policies (moindre privilège) et les
roles qui relient une identité K8s à une policy. Le pendant côté cluster est dans ../k8s/.
VSO présente le token JWT du ServiceAccount de l'app. Vault le valide via l'API
TokenReview du cluster, puis vérifie qu'il correspond à un role
(bound_service_account_names + _namespaces + audience). Si oui, Vault renvoie un token
porteur des policies du role — donc des droits de lecture précis, et rien d'autre.
JWT du SA (audience "vault") ─► auth/kubernetes/config (TokenReview) ─► role ─► policy
│
kvv2/ · database/ · pki/ · transit/ ◄─────────────┘
Casser un seul maillon (nom du SA, namespace, audience, chemin de policy, nom de mount) donne un
403 côté VSO et un SecretSynced: false sur le CR. C'est l'écrasante majorité des pannes.
exportVAULT_ADDR="https://vault.talos.lab.example.io"# ou http://127.0.0.1:8200 en port-forwardexportVAULT_TOKEN="<root-token>"# cf. ../../vault-cluster/LISEZ-MOI.md
vaultstatus# doit répondre Sealed=false
Port-forward si Vault n'est pas exposé :
kubectl -n vault port-forward svc/vault-active 8200:8200.
⚠️ VAULT_ADDR/VAULT_TOKEN posés dans lab.env n'ont AUCUN effet : aucun script ne lit ce
fichier. Il faut les exporter (ou set -a; . ./lab.env; set +a). Détail et précautions dans
../../vault-cluster/LISEZ-MOI.md (section Pièges).
Le dossier contient deux jeux de scripts qui ne servent pas la même chose. Ne pas les mélanger :
ils utilisent des mounts, des namespaces et des noms de role différents.
C'est le chemin qui tourne vraiment : mount KV-v2 talos-lab/ (un sous-dossier par appli) et
rotation d'un mot de passe PostgreSQL par static role. Les démos K8s correspondantes sont
../k8s/nginx-test-vault/ et ../k8s/pg-dynamic-rotate/.
Parcours B — la démo pédagogique (mounts kvv2/, pki/, transit/)#
cd_k8s/vault-secret-operator/vault
bash00-secrets-engines.sh# moteurs + un secret de démo + CA PKI + clé transitMODE=inclusterbash01-kubernetes-auth.sh# auth/kubernetes (voir le piège plus bas !)
bash02-roles.sh# policies + roles vso-static/-dynamic/-pki/-transit
Elle sert de support de lecture pour les CR numérotés de ../k8s/ (10-, 20-, 30-, 40-).
Deux de ses trois scripts ont des défauts documentés en ⚠️ Pièges — les lire avant.
La connexion et le role du moteur database sont laissés en commentaire dans le script (ils
dépendent de ta base). Le parcours A, lui, les écrit pour de vrai (pg-dynamic-rotate.sh).
Deux modes, selon où tourne Vault. C'est le point qui bloque le plus.
MODE=incluster (notre cas) — Vault appelle TokenReview avec le token de son propre pod :
son ServiceAccount doit porter le ClusterRole system:auth-delegator, ce que le chart
hashicorp/vault fait par défaut (server.authDelegator.enabled=true). La config se réduit alors
à kubernetes_host ; token_reviewer_jwt et kubernetes_ca_cert restent vides (Vault utilise le
CA monté dans son conteneur). disable_iss_validation reste à true (défaut ≥ Vault 1.9).
MODE=external — Vault est hors du cluster, il ne peut rien déduire. Il faut lui fournir :
un ServiceAccountdélégateur côté K8s (system:auth-delegator) ;
son token long (token_reviewer_jwt), avec lequel Vault validera les JWT des apps ;
l'endpoint de l'API (KUBE_HOST, par défaut la VIP du lab https://192.168.56.5:6443,
cf. talos/patch-cp.yaml) et le CA de cette API (SA_CA_CRT).
Charge les 4 fichiers de policies/, puis crée 4 roles auth/kubernetes. Tous en token_ttl=15m
et audience=vault (qui doit matcher VaultAuth.spec.kubernetes.audiences côté K8s).
Auth Kubernetes (kubernetes_host=https://kubernetes.default.svc), moteur KV-v2 talos-lab/,
secret talos-lab/nginx-test-vault/config (3 clés APP_*), policy
talos-lab-nginx-test-vault (lecture de talos-lab/data|metadata/nginx-test-vault/* seulement) et
role nginx-test-vault bindé au SA/ns nginx-test-vault.
Ajouter une appli = un sous-dossier talos-lab/<appli>/…, une policy scopée à ce sous-dossier, un
role dédié au SA/ns de l'appli. Ce script est le gabarit à copier.
pg-dynamic-rotate.sh — rotation du mot de passe PostgreSQL#
Vault prend en gestion un user PG fixe (vault-rotate) et n'en rotate que le mot de passe
(static role) — la chaîne de connexion de l'app reste stable. Vue d'ensemble du scénario et
prérequis PostgreSQL : ../LISEZ-MOI.md.
Une policy = un usage, scopée au chemin exact. Aucun wildcard de mount.
Fichier
Autorise
policies/vso-static-kv.hcl
read sur kvv2/data/demo/app + kvv2/metadata/demo/app
policies/vso-dynamic-db.hcl
read sur db/creds/demo-app (⚠️ mount db/ — cf. Pièges) + update sur sys/leases/renew|revoke
policies/vso-pki.hcl
create/update sur pki/issue/demo et pki/revoke
policies/vso-transit-cache.hcl
encrypt/decrypt de la clé vso-client-cache (opérateur)
policies/talos-lab-nginx-test-vault.hcl
read sur talos-lab/data|metadata/nginx-test-vault/*
ℹ️ KV-v2 : le chemin de policy est <mount>/data/<path> pour les données et
<mount>/metadata/<path> pour les versions — pas<mount>/<path>. Erreur classique.
vaultstatus# Sealed=false
vaultsecretslist# kvv2/, database/, pki/, transit/, talos-lab/
vaultauthlist# kubernetes/ présent
vaultreadauth/kubernetes/config# kubernetes_host DOIT être une vraie URL
vaultlistauth/kubernetes/role# vso-*, nginx-test-vault, pg-rotate-demo
vaultpolicylist
vaultkvgetkvv2/demo/app# secret de démo (parcours B)
vaultkvgettalos-lab/nginx-test-vault/config# secret du lab (parcours A)
vaultlistpki/issuers# UNE seule CA attendue (cf. Pièges)# Test de login « à blanc », sans passer par VSO — isole les problèmes d'identité :JWT=$(kubectl-ndemocreatetokenvso-app--audience=vault)
vaultwriteauth/kubernetes/loginrole=vso-staticjwt="$JWT"# doit renvoyer un token + sa policy
La démo « dynamic DB » ne peut pas fonctionner en l'état — décalage de mount.00-secrets-engines.sh:19 fait vault secrets enable database, sans -path : le moteur est
donc monté sur database/. Or ../k8s/20-dynamic-db.yaml:11 demande mount: db et
policies/vso-dynamic-db.hcl:5 n'autorise que db/creds/demo-app. Le VaultDynamicSecret ne
peut renvoyer qu'un 403 ou un 404, quoi qu'on fasse par ailleurs. Pour aligner le tout sur
db/, il faut monter le moteur au bon endroit :
vaultsecretsenable-path=dbdatabase# au lieu de : vault secrets enable database
(Le parcours A n'est pas concerné : pg-dynamic-rotate.sh écrit et lit cohéremment database/.)
01-kubernetes-auth.sh en mode incluster casse tous les logins. La ligne 26 écrit
kubernetes_host="https://\$KUBERNETES_PORT_443_TCP_ADDR:443" : le \$ est un $littéral
en bash, et Vault ne fait aucune substitution d'environnement sur les valeurs de config. Vault
stocke donc la chaîne telle quelle, et toute authentification via auth/kubernetes échoue.
Le script frère fait correctement kubernetes_host="https://kubernetes.default.svc"
(talos-lab.sh:22). Diagnostic :
vaultreadauth/kubernetes/config# si kubernetes_host contient un "$", c'est ce bug
vaultwriteauth/kubernetes/configkubernetes_host="https://kubernetes.default.svc"# correctif
Les scripts 00-/01-/02- ne sont PAS idempotents, contrairement à ce que dit leur
en-tête. Trois raisons distinctes :
00-secrets-engines.sh:16 (vault kv put kvv2/demo/app …) et talos-lab.sh:31-34
(APP_SECRET_TOKEN) réécrasent la valeur et créent une nouvelle version KV à chaque
relance. Si tu as fait tourner le secret pour observer la resync du VSO, relancer le script
remet silencieusement la valeur d'origine.
00-secrets-engines.sh:35-36 : depuis Vault 1.11 (multi-issuers), pki/root/generate/internaln'échoue plus sur un mount déjà configuré — il ajoute simplement un nouvel émetteur.
Le garde-fou || echo "(CA racine déjà générée)" ne se déclenche donc jamais, et chaque
relance empile une CA racine de plus. Contrôler avec vault list pki/issuers et supprimer
les doublons (vault delete pki/issuer/<id>).
pg-dynamic-rotate.sh réécrit database/config/pg-demo et le static role à chaque passage.
C'est le mécanisme prévu pour changer ROTATION_PERIOD, mais ce n'est pas neutre.
Aucune garde d'environnement dans 00-/01-/02-, contrairement à talos-lab.sh:14-15 et
pg-dynamic-rotate.sh:21-22 qui refusent de démarrer sans VAULT_ADDR/VAULT_TOKEN. Sans
VAULT_ADDR, le CLI tape silencieusement https://127.0.0.1:8200 ; le script ne s'arrête qu'à
la ligne 38 de 00- (${VAULT_ADDR} sous set -u → unbound variable), donc après avoir
déjà tenté d'écrire. Exporter les deux variables avant de lancer quoi que ce soit.
00-secrets-engines.sh:11 masque toutes les erreurs Vault. Le helper
enable() { vault secrets enable "$@" 2>/dev/null || echo " (déjà activé : $*)"; } avale
stderr : un Vault is sealed, un permission denied ou un connection refused s'affichent
comme « (déjà activé) ». Le script s'interrompra bien à la commande suivante (set -e), mais
avec un diagnostic trompeur. En cas de doute, relancer la commande à la main sans 2>/dev/null.
permission denied au login : le SA/namespace du pod ne matche pas
bound_service_account_names/_namespaces, ou l'audience du JWT ≠ audience du role. Le
test de login « à blanc » de la section ✅ isole le problème sans impliquer VSO.
error validating token: … 403 : le reviewer n'a pas system:auth-delegator (in-cluster), ou
le token_reviewer_jwt est faux/expiré (externe).
Ne pas remettre disable_iss_validation=false : le défaut est true depuis Vault 1.9, et
l'iss des tokens projetés varie → logins cassés.
Secrets de démo en clair dans les scripts.00-secrets-engines.sh et talos-lab.sh posent
des valeurs bidon, versionnées dans git. C'est assumé pour un lab ; en prod, les valeurs
s'injectent hors git (vault kv put … @- depuis un pipe, ou un vrai flux d'approvisionnement).
🐘cloudnative-pg/ — declarative PostgreSQL HA (CloudNativePG operator)
One Cluster CRD = one complete PostgreSQL HA setup. The operator watches that object and
reconciles the actual state: it creates the pods, mounts the PVCs, elects a primary, attaches
streaming replicas, and fails over on its own if the primary dies. The textbook case of the
operator pattern.
Demonstrate the operator pattern: you describe the desired state, the controller does the
rest (provisioning, replication, automatic failover, rolling updates, backups).
Show a live failover — a 30 s demo (see 🧪 Scenarios).
Give the other components a real database: credentials rotated by Vault
(../vault-secret-operator/), S3 backups to MinIO (../minio-s3/cluster/).
What gets deployed: the operator (1 pod, ns cnpg-system) plus a demo cluster pg-demo
(ns cnpg-demo, 3 instances = 1 primary + 2 replicas, 1Gi RWO PVCs on longhorn-r1).
Two layers of resilience (keep them apart when teaching)#
PostgreSQL replication (logical): the primary streams its WAL to 2 replicas →
application-level failover if the primary is lost.
Longhorn replication (block): a PVC can be replicated by Longhorn across several
nodes → survives the loss of a disk.
These are two independent mechanisms. This lab's choice: 1 Longhorn replica for the
database PVCs (dedicated longhorn-r1 StorageClass), because PostgreSQL already replicates at
the application level — stacking 3 block replicas × 3 instances = 9 copies of the same dataset,
which fills up the shared OS disk (~20 GB). If a node dies, CNPG rebuilds the lost instance
from the primary. That is the recommended pattern for a database operator on Longhorn, and a good
hook for explaining "application replication vs storage replication" — and when not to double
up.
storage for the 3 instances; the script aborts (exit 1) if it is missing
kubectl get sc longhorn-r1
3 workers
the default anti-affinity places 1 instance per worker
kubectl get nodes
ℹ️ longhorn-r1 is not created by this component.cluster-demo.yaml only
references it; it is defined in _k8s/longhorn/longhorn-r1-storageclass.yaml.
With fewer than 3 workers, lower instances in cluster-demo.yaml (otherwise one pod stays
Pending).
kubectlapply-f_k8s/longhorn/longhorn-r1-storageclass.yaml# if not already done
./_k8s/cloudnative-pg/cloudnative-pg-up.sh
Version pinned in the script: chart cnpg/cloudnative-pg 0.29.0 (app v1.30.0),
overridable with CNPG_VERSION=…. Idempotent (helm upgrade --install + kubectl apply).
checks kubectl/helm, the apiserver, and that the longhorn-r1 SC is present;
installs the operator in cnpg-system with values.yaml, then waits for the rollout;
applies cluster-demo.yaml (namespace cnpg-demo + Clusterpg-demo) and waits for
condition=Ready (300 s max, without failing if the deadline is exceeded).
kubectl-ncnpg-demogetclusterpg-demo-ojsonpath='{.status.currentPrimary}';echo# e.g. pg-demo-1
kubectl-ncnpg-demodeletepodpg-demo-1# kill the primary
watchkubectl-ncnpg-demogetclusterpg-demo# a replica is promoted within seconds
The operator promotes a replica and recreates the old primary as a replica, with no intervention.
Write some data, delete a pod: the PVC is reattached, the data survives. Delete the node (VM):
CNPG rebuilds the instance from the primary (with longhorn-r1 the block is not replicated
elsewhere — PostgreSQL is what catches up, see the two layers above).
The Secret pg-demo-app holds a ready-to-use uri. Ideal for wiring up a demo app, or for
pairing with Vault/VSO for rotated credentials (see ../vault-secret-operator/).
Both push to MinIO and therefore require the ../minio-s3/cluster/ component (namespace
minio-cluster, Secret minio-creds: both scripts read the MinIO root password and create a
bucket plus a dedicated user through a port-forward).
A. Hourly logical backup through the Vault credentials#
pg_dump backup of the vault database, every hour, pushed to the pg-backups bucket —
connecting to PostgreSQL with the credentials rotated by Vault.
CronJob pg-backup-vault-s3 (ns pg-rotate-demo, schedule "0 * * * *")
│ pg_dump "$DATABASE_URL" (Vault creds from Secret pg-rotate-creds, sslmode=require)
▼ vault-<timestamp>.sql.gz
└─ mc cp ──► MinIO bucket pg-backups (dedicated MinIO user pg-backup, scoped to the bucket)
Prerequisites on top of MinIO: Vault rotation already in place (Secret pg-rotate-creds in
pg-rotate-demo — the script refuses to go on without it) and the pg-demo cluster UP.
./_k8s/cloudnative-pg/pg-backup-up.sh# MinIO bucket + user + Secret minio-backup-creds + CronJob# Trigger an immediate backup to check:
kubectl-npg-rotate-democreatejobpg-backup-now--from=cronjob/pg-backup-vault-s3
kubectl-npg-rotate-demologsjob/pg-backup-now
⚠️ This backup does NOT go through the CloudNativePG CRDs. No Backup/ScheduledBackup,
no barmanObjectStore: it is a pg_dump carried by a plain CronJob, chosen on purpose so
it can use the Vault creds — something the native CNPG backup cannot do.
B. Native CloudNativePG backup (CRD → MinIO, with PITR)#
Physical backup of the whole pg-demo instance (app database included) + continuous WAL
archiving, pushed by barman-cloud. Enables point-in-time restore.
Cluster pg-demo ── spec.backup.barmanObjectStore ──► MinIO bucket cnpg-backups/pg-demo/
├─ base/<timestamp>/data.tar.gz (base backup, triggered by Backup/ScheduledBackup)
└─ wals/.../*.gz (CONTINUOUS WAL archiving => PITR)
ScheduledBackup pg-demo-hourly ── "0 0 * * * *" (6-field cron: sec min h …) ──► base backups
💡 Restore (PITR): you create a newCluster with spec.bootstrap.recovery pointing at
the same barmanObjectStore (+ recoveryTarget for a point in time). You do not restore
"into" the existing cluster. See the CNPG "Recovery" docs.
Cluster stuck at Creating a new replica → normal provisioning (bootstrap + join);
allow 2-5 min. Otherwise check the PVCs (kubectl -n cnpg-demo get pvc) and Longhorn.
A replica stays Pending → the anti-affinity wants 1 instance per worker: not enough
workers. Lower instances or add a worker (WORKERS in lab.env).
PVC Pending → longhorn-r1 StorageClass missing, or Longhorn down (see ../longhorn/).
Volumes Degraded on the Longhorn side → Longhorn defaultReplicaCount > number of
workers.
pg-backup-up.sh: "secret pg-rotate-creds missing" → Vault rotation is not in place: run
through ../vault-secret-operator/ (rotation section) first.
CNPG backup stuck in running/failed → check the archiving condition:
kubectl -n cnpg-demo get cluster pg-demo -o jsonpath='{.status.conditions}'
(ContinuousArchiving must be True), then the logs of the primary instance's sidecar.
Longhorn faulted / ReplicaSchedulingFailure: insufficient storage — already hit on
this lab: with default-replica-count=3 and the shared OS disk (~20 GB), 3 block replicas
× 3 instances do not fit. Hence longhorn-r1. Diagnosis:
kubectl -n longhorn-system get volume <pvc> -o jsonpath='{.status.conditions}'.
No pg-demo-superuser Secret by default. Since CNPG 1.21, enableSuperuserAccess
defaults to false and cluster-demo.yaml does not set it back: the Secret therefore does
not exist. But ../vault-secret-operator/vault/pg-dynamic-rotate.sh reads it to configure
Vault's database/ engine → it fails on the first run. Do this first:
The pg-backups bucket has NO expiration rule at all. The CronJob is hourly → ~8,760
objects a year, on a MinIO bucket sitting on local-path (4 × 10Gi, EC:2) and with no quota.
Nothing prunes it, neither the script nor the manifest. In a lab: keep an eye on it, or add a
lifecycle rule by hand on the MinIO side (mc ilm rule add, see the MinIO docs). The
native backup, on the other hand, is bounded (retentionPolicy: "7d" set by
pg-app-backup-cnpg-up.sh).
The CronJob downloads mc from the Internet on EVERY run.pg-backup-vault-s3.yaml does a wget https://dl.min.io/…/mc inside the container, with no
pinned version and no checksum verification: with no outbound access (or if dl.min.io
moves), the hourly backup fails. Your rescue path therefore depends on the Internet —
acceptable in a lab, to be replaced by an image that ships mc for real.
In-tree barmanObjectStore is deprecated. On CNPG 1.30 it still works but its removal
is announced for 1.31.0. Migration path: the Barman Cloud Plugin (CNPG-I, an
ObjectStore object + plugin on the Cluster). The principle (base + WAL → S3/MinIO, PITR)
is identical; only the declaration changes.
No metrics in Prometheus: that is expected, everything is off by default. After installing
../observability/, set monitoring.enablePodMonitor: true in cluster-demo.yaml (instance
metrics) andmonitoring.podMonitorEnabled: true in values.yaml (operator metrics), then
re-run the script. The PodMonitor CRD only exists after kube-prometheus-stack — hence the
order.
🐘cloudnative-pg/ — PostgreSQL HA déclaratif (opérateur CloudNativePG)
Un CRD Cluster = un PostgreSQL HA complet. L'opérateur observe cet objet et réconcilie
l'état réel : il crée les pods, monte les PVC, élit un primaire, attache les réplicas en
streaming, et bascule tout seul si le primaire tombe. Le cas d'école du pattern operator.
Démontrer le pattern operator : on décrit l'état voulu, le contrôleur fait le reste
(provisioning, réplication, failover automatique, rolling updates, backups).
Montrer un failover en direct — 30 s de démo (cf. 🧪 Scénarios).
Fournir une vraie base aux autres addons : identifiants rotés par Vault
(../vault-secret-operator/), sauvegardes S3 vers MinIO (../minio-s3/cluster/).
Ce qui est déployé : l'opérateur (1 pod, ns cnpg-system) + un cluster de démo pg-demo
(ns cnpg-demo, 3 instances = 1 primaire + 2 réplicas, PVC 1Gi RWO sur longhorn-r1).
Deux couches de résilience (à bien distinguer en formation)#
Réplication PostgreSQL (logique) : le primaire streame ses WAL vers 2 réplicas →
bascule applicative en cas de perte du primaire.
Réplication Longhorn (bloc) : un PVC peut être répliqué par Longhorn sur plusieurs
nodes → survie à la perte d'un disque.
Ce sont deux mécanismes indépendants. Choix de ce lab : 1 réplica Longhorn pour les PVC
de la base (StorageClass dédiée longhorn-r1), car PostgreSQL réplique déjà au niveau
applicatif — empiler 3 réplicas bloc × 3 instances = 9 copies du même jeu de données, ce qui
sature le disque OS partagé (~20 Go). Si un node meurt, CNPG reconstruit l'instance perdue
depuis le primaire. C'est le pattern recommandé pour un opérateur de BDD sur Longhorn, et un
bon support pour expliquer « réplication applicative vs réplication stockage » — et quand ne
pas doubler.
stockage des 3 instances ; le script s'arrête (exit 1) si elle manque
kubectl get sc longhorn-r1
3 workers
l'anti-affinité par défaut place 1 instance par worker
kubectl get nodes
ℹ️ longhorn-r1 n'est pas créée par cet addon.cluster-demo.yaml ne fait que la
référencer ; elle est définie dans _k8s/longhorn/longhorn-r1-storageclass.yaml.
Avec moins de 3 workers, réduire instances dans cluster-demo.yaml (sinon un pod reste
Pending).
kubectl-ncnpg-demogetclusterpg-demo# READY 3/3, "Cluster in healthy state"
kubectl-ncnpg-demogetpods-lcnpg.io/cluster=pg-demo# pg-demo-1/2/3 Running, 1 par worker
kubectl-ncnpg-demogetpvc# 3 PVC Bound, 1Gi longhorn-r1# Se connecter et lire (via le pod primaire)
kubectl-ncnpg-demoexec-itpg-demo-1--psql-c'\l'# liste les bases (dont `app`)
Le plugin kubectl-cnpg donne une vue riche (à installer côté hôte, optionnel) :
kubectl-ncnpg-demogetclusterpg-demo-ojsonpath='{.status.currentPrimary}';echo# ex: pg-demo-1
kubectl-ncnpg-demodeletepodpg-demo-1# on tue le primaire
watchkubectl-ncnpg-demogetclusterpg-demo# un réplica est promu en quelques s
L'opérateur promeut un réplica, recrée l'ancien primaire en réplica, sans intervention.
Écris des données, supprime un pod : le PVC est réattaché, les données survivent. Supprime le
node (VM) : CNPG reconstruit l'instance depuis le primaire (avec longhorn-r1, le bloc n'est
pas répliqué ailleurs — c'est PostgreSQL qui rattrape, cf. les deux couches plus haut).
Le Secret pg-demo-app contient une uri prête à l'emploi. Idéal pour brancher une appli de
démo, ou coupler avec Vault/VSO pour des identifiants rotés (cf. ../vault-secret-operator/).
Les deux poussent vers MinIO et exigent donc l'addon ../minio-s3/cluster/ (namespace
minio-cluster, Secret minio-creds : les deux scripts lisent le mot de passe root MinIO et
créent bucket + utilisateur dédié via un port-forward).
A. Backup logique horaire via les identifiants Vault#
Sauvegarde pg_dump de la base vault, toutes les heures, poussée dans le bucket
pg-backups — en se connectant à PostgreSQL avec les identifiants rotés par Vault.
Prérequis en plus de MinIO : la rotation Vault en place (Secret pg-rotate-creds dans
pg-rotate-demo — le script refuse de continuer sans lui) et le cluster pg-demo UP.
./_k8s/cloudnative-pg/pg-backup-up.sh# bucket + user MinIO + Secret minio-backup-creds + CronJob# Déclencher un backup immédiat pour vérifier :
kubectl-npg-rotate-democreatejobpg-backup-now--from=cronjob/pg-backup-vault-s3
kubectl-npg-rotate-demologsjob/pg-backup-now
⚠️ Ce backup NE passe PAS par les CRD de CloudNativePG. Ni Backup/ScheduledBackup,
ni barmanObjectStore : c'est un pg_dump porté par un CronJob standard, choisi exprès
pour utiliser les creds Vault — ce que le backup natif CNPG ne sait pas faire.
B. Backup natif CloudNativePG (CRD → MinIO, avec PITR)#
Backup physique de toute l'instance pg-demo (base app incluse) + archivage WAL
continu, poussé par barman-cloud. Permet la restauration à un instant T.
💡 Restauration (PITR) : on crée un nouveauCluster avec spec.bootstrap.recovery
pointant sur le même barmanObjectStore (+ recoveryTarget pour un instant T). On ne
restaure pas « dans » le cluster existant. Voir la doc CNPG « Recovery ».
Cluster bloqué en Creating a new replica → provisioning normal (bootstrap + join) ;
compter 2-5 min. Sinon vérifier les PVC (kubectl -n cnpg-demo get pvc) et Longhorn.
Un réplica reste Pending → l'anti-affinité veut 1 instance/worker : pas assez de
workers. Réduire instances ou ajouter un worker (WORKERS de lab.env).
PVC Pending → StorageClass longhorn-r1 absente ou Longhorn KO (voir ../longhorn/).
Volumes Degraded côté Longhorn → defaultReplicaCount Longhorn > nombre de workers.
pg-backup-up.sh : « secret pg-rotate-creds absent » → la rotation Vault n'est pas en
place : dérouler ../vault-secret-operator/ (section rotation) d'abord.
Backup CNPG qui reste running/failed → vérifier la condition d'archivage :
kubectl -n cnpg-demo get cluster pg-demo -o jsonpath='{.status.conditions}'
(ContinuousArchiving doit être True), puis les logs du sidecar de l'instance primaire.
Longhorn faulted / ReplicaSchedulingFailure: insufficient storage — déjà rencontré
sur ce lab : avec default-replica-count=3 et le disque OS partagé (~20 Go), 3 réplicas
bloc × 3 instances ne rentrent pas. D'où longhorn-r1. Diagnostic :
kubectl -n longhorn-system get volume <pvc> -o jsonpath='{.status.conditions}'.
Pas de Secret pg-demo-superuser par défaut. Depuis CNPG 1.21,
enableSuperuserAccess vaut false et cluster-demo.yaml ne le repositionne pas : le
Secret n'existe donc pas. Or ../vault-secret-operator/vault/pg-dynamic-rotate.sh le lit
pour configurer le moteur database/ de Vault → il échoue au premier lancement. À faire
avant :
Le bucket pg-backups n'a AUCUNE règle d'expiration. Le CronJob est horaire → ~8 760
objets par an, sur un bucket MinIO posé sur local-path (4 × 10Gi, EC:2) et sans quota.
Rien ne purge, ni le script ni le manifeste. En lab : surveiller, ou poser une règle de cycle
de vie à la main côté MinIO (mc ilm rule add, cf. doc MinIO). Le backup natif,
lui, est borné (retentionPolicy: "7d" posé par pg-app-backup-cnpg-up.sh).
Le CronJob télécharge mc depuis Internet à CHAQUE exécution.pg-backup-vault-s3.yaml fait wget https://dl.min.io/…/mc dans le conteneur, sans
version épinglée ni vérification de checksum : sans accès sortant (ou si dl.min.io
bouge), le backup horaire échoue. Le sauvetage dépend donc d'Internet — acceptable en lab,
à remplacer par une image contenant mc en vrai.
barmanObjectStore in-tree est déprécié. Sur CNPG 1.30 il fonctionne mais son
retrait est annoncé en 1.31.0. Migration : le Barman Cloud Plugin (CNPG-I, objet
ObjectStore + plugin sur le Cluster). Le principe (base + WAL → S3/MinIO, PITR) est
identique ; seule la déclaration change.
Métriques absentes de Prometheus : c'est normal, tout est coupé par défaut. Après
l'install de ../observability/, passer monitoring.enablePodMonitor: true dans
cluster-demo.yaml (métriques des instances) etmonitoring.podMonitorEnabled: true
dans values.yaml (métriques de l'opérateur), puis relancer le script. Le CRD PodMonitor
n'existe qu'après kube-prometheus-stack — d'où l'ordre.
The lab's observability stack, in one command: kube-prometheus-stack (Prometheus,
Grafana, Alertmanager, node-exporter, kube-state-metrics) + Loki (logs) + Grafana
Alloy (collection). Three UIs over HTTPS behind main-gateway.
🌐 talos.lab.example.io is the repo's NEUTRAL (public) domain: observability-up.sh
replaces it with LAB_DOMAIN (lab.env) in the Helm values and in the HTTPRoutes. See
../README.md.
Alloy in file mode (not API). Reading logs through loki.source.kubernetes (k8s API)
pushes every log line through the kube-apiserver → huge load (it contributed to this lab's
CP incident). Here Alloy reads /var/log/pods directly on each node (one DaemonSet, each node
handling its own share); discovery.kubernetes is only used for labelling (a lightweight
metadata watch).
longhorn-r1 storage (1 block replica). Metrics and logs are rebuildable: no need to
replicate the blocks 3×, that would fill up the shared OS disk.
PVCs for Prometheus (3Gi), Loki (3Gi), Grafana (1Gi); the script aborts without it
kubectl get sc longhorn-r1
4 GB control planes (CP_MEM=4096 in lab.env)
this stack loads the apiserver (scrapes + watches)
talosctl -n <cp> dashboard
⚠️ Control-plane RAM. On 3 GB CPs, stacking this pile on top of the rest of the lab
saturates etcd/apiserver (lived through it: OOM loop, API unreachable). At 4 GB, the
stack sits at ~50 % of CP memory. Anything under CP_MEM=3072 is below the
minimum, and 2 GB already starve etcd on their own. Fix lab.envbefore installing,
then vagrant reload the CPs one at a time.
Loki SingleBinary + filesystem on a 3Gi longhorn-r1 PVC; memcached caches turned off (otherwise ~9 GB of RAM requested)
alloy-values.yaml
Alloy DaemonSet, file mode (/var/log/pods) → Loki; does NOT load the apiserver
httproutes.yaml
3 HTTPS HTTPRoutes on main-gateway (wildcard TLS already carried by the listener)
observability-up.sh
Installs everything in order (idempotent)
ℹ️ Control-plane monitors disabled (kubeControllerManager, kubeScheduler,
kubeEtcd, kubeProxy set to false): on Talos they are not scrapable without dedicated TLS
config → this only avoids noisy "down" targets during training.
404 on the UIs right after the install → Envoy still propagating the HTTPRoutes; retry
after ~30 s.
CPs saturating / apiserver flapping → undersized CPs: move to 4 GB
(CP_MEM, then vagrant reload the CPs one at a time).
PVC Pending / ReplicaSchedulingFailure → longhorn-r1 missing, or disk full
(lower the retention or the PVC sizes).
No logs in Loki → is there one Alloy per node in 2/2? kubectl -n monitoring get ds alloy.
Then check loki.write in Alloy's logs.
A pod "with no logs" → usually just too narrow a time range: healthy pods
(prometheus, node-exporter…) log at startup then go quiet. Widen the window (12-24 h).
Control-plane logs (apiserver/scheduler/controller-manager) → these are static
pods: their /var/log/pods directory is named <ns>_<pod>_<HASH> (config hash), not the
API <uid>. Alloy's __path__ matches on <ns>_<pod>_* to cover both cases.
etcd escapes Loki: on Talos it is not a k8s pod but a Talos service →
talosctl logs etcd (shipping it to Loki would need a dedicated shipper).
Loki retention relies on the compactor, not on retention_period. In Loki,
limits_config.retention_period only states the limit: the deletion itself is the
compactor's job, and its retention_enabled defaults to false. A configuration that
only sets retention_period therefore lets logs pile up until the disk is full.
loki-values.yaml enables both (24 h retention, retention_enabled: true,
delete_request_store: filesystem, effective purge after retention_delete_delay: 2h).
Check that the block really is rendered:
Prometheus has no retentionSize. There is only retention: 2d, which bounds the age
of the series, not the volume they take: a cardinality spike (new ServiceMonitors, churning
pods) can fill the 3 Gi before the 2 days are up, and Prometheus then goes into a write error.
A retentionSize: 2GiB in prometheusSpec would bound both.
Grafana keeps the chart's default admin password (documented in a comment in
kube-prometheus-stack-values.yaml, and printed in clear text by observability-up.sh at
the end of its run) — while the UI is exposed over public HTTPS with a prod Let's
Encrypt certificate (so a resolvable name and a trusted cert). A lab, yes, but a reachable
one: change the password on the first login, or go through
grafana.admin.existingSecret.
Prometheus and Alertmanager are exposed with NO authentication at all (no filter on
the HTTPRoutes): anyone who reaches the Gateway can read every metric and silence
alerts.
Nothing emits application metrics by default.serviceMonitorSelectorNilUsesHelmValues: false does make Prometheus scrape every
ServiceMonitor/PodMonitor in the cluster… but every emitter in the lab is switched off.
Flip them to trueafter this install (the ServiceMonitor/PodMonitor CRDs only exist
afterwards), then re-run the *-up.sh of the component concerned:
La pile d'observabilité du lab, en une commande : kube-prometheus-stack (Prometheus,
Grafana, Alertmanager, node-exporter, kube-state-metrics) + Loki (logs) + Grafana
Alloy (collecte). Trois UI en HTTPS derrière main-gateway.
🌐 talos.lab.example.io est le domaine NEUTRE du dépôt (public) : observability-up.sh
le remplace par LAB_DOMAIN (lab.env) dans les values Helm et les HTTPRoute. Cf.
../LISEZ-MOI.md.
Alloy en mode fichier (pas API). Lire les logs via loki.source.kubernetes (API k8s)
fait transiter tous les logs à travers le kube-apiserver → charge énorme (a contribué à
l'incident CP de ce lab). Ici Alloy lit directement /var/log/pods sur chaque node (un
DaemonSet, une part par node) ; discovery.kubernetes ne sert qu'à étiqueter (watch
léger de métadonnées).
Stockage longhorn-r1 (1 réplica bloc). Métriques et logs sont reconstructibles : pas
besoin de répliquer les blocs 3×, ça saturerait le disque OS partagé.
PVC de Prometheus (3Gi), Loki (3Gi), Grafana (1Gi) ; le script s'arrête sans elle
kubectl get sc longhorn-r1
CP à 4 Go (CP_MEM=4096 dans lab.env)
cette pile charge l'apiserver (scrapes + watches)
talosctl -n <cp> dashboard
⚠️ RAM des control-plane. Sur des CP à 3 Go, empiler cette pile sur le reste du lab
sature etcd/apiserver (incident vécu : OOM en boucle, API injoignable). À 4 Go, la
pile tient à ~50 % de la mémoire CP. Tout ce qui est sous CP_MEM=3072 est sous le
minimum, et 2 Go affament déjà etcd tout seuls. Corriger lab.envavant d'installer,
puis vagrant reload des CP un par un.
Loki SingleBinary + filesystem sur PVC 3Gi longhorn-r1 ; caches memcached coupés (sinon ~9 Go de RAM demandés)
alloy-values.yaml
Alloy DaemonSet, mode fichier (/var/log/pods) → Loki ; ne charge PAS l'apiserver
httproutes.yaml
3 HTTPRoute HTTPS sur main-gateway (TLS wildcard déjà porté par l'écouteur)
observability-up.sh
Installe tout dans l'ordre (idempotent)
ℹ️ Moniteurs control-plane désactivés (kubeControllerManager, kubeScheduler,
kubeEtcd, kubeProxy à false) : sur Talos ils ne sont pas scrapables sans config TLS
dédiée → ça n'évite que des cibles « down » bruyantes en formation.
404 sur les UI juste après l'install → propagation Envoy des HTTPRoute ; réessayer après ~30 s.
CP qui saturent / apiserver qui flappe → CP sous-dimensionnés : passer à 4 Go
(CP_MEM, puis vagrant reload des CP un par un).
PVC Pending / ReplicaSchedulingFailure → longhorn-r1 absente, ou disque plein
(baisser la rétention ou les tailles de PVC).
Pas de logs dans Loki → un Alloy par node en 2/2 ? kubectl -n monitoring get ds alloy.
Puis vérifier loki.write dans les logs d'Alloy.
Un pod « sans logs » → souvent juste une plage temporelle trop courte : les pods sains
(prometheus, node-exporter…) loguent au démarrage puis se taisent. Élargir la fenêtre (12-24 h).
Logs du control-plane (apiserver/scheduler/controller-manager) → ce sont des static
pods : leur dossier /var/log/pods est nommé <ns>_<pod>_<HASH> (hash de config), pas
<uid> API. Le __path__ d'Alloy matche par <ns>_<pod>_* pour couvrir les deux cas.
etcd échappe à Loki : sur Talos ce n'est pas un pod k8s mais un service Talos →
talosctl logs etcd (il faudrait un shipper dédié pour l'envoyer à Loki).
La rétention Loki repose sur le compactor, pas sur retention_period. Dans Loki,
limits_config.retention_period ne fait qu'exprimer la limite : la suppression est le
travail du compactor, dont retention_enabled vaut false par défaut. Une configuration
qui ne pose que retention_period laisse donc les logs s'accumuler jusqu'au disque plein.
loki-values.yaml active les deux (rétention 24 h, retention_enabled: true,
delete_request_store: filesystem, purge effective après retention_delete_delay: 2h).
Vérifier que le bloc est bien rendu :
Prometheus n'a pas de retentionSize. Il n'y a que retention: 2d, qui borne l'âge
des séries, pas le volume occupé : un pic de cardinalité (ajout de ServiceMonitors, pods
qui churnent) peut remplir les 3 Gi avant les 2 jours, et Prometheus se met alors en erreur
d'écriture. Un retentionSize: 2GiB dans prometheusSpec bornerait les deux.
Grafana garde le mot de passe admin par défaut du chart (documenté en commentaire dans
kube-prometheus-stack-values.yaml, et affiché en clair par observability-up.sh en fin
d'exécution) — alors que l'UI est exposée en HTTPS public avec un certificat Let's
Encrypt prod (donc un nom résolvable et un cert trusté). Un lab, oui, mais joignable :
changer le mot de passe dès la première connexion, ou passer par
grafana.admin.existingSecret.
Prometheus et Alertmanager sont exposés SANS aucune authentification (aucun filtre sur
les HTTPRoute) : quiconque atteint la Gateway peut lire toutes les métriques et silencer
des alertes.
Rien n'émet de métriques applicatives par défaut.serviceMonitorSelectorNilUsesHelmValues: false fait bien que Prometheus scrape tous les
ServiceMonitor/PodMonitor du cluster… mais tous les émetteurs du lab sont coupés. À
basculer à trueaprès cette install (les CRD ServiceMonitor/PodMonitor n'existent
qu'ensuite), puis relancer le *-up.sh de l'addon concerné :
🩺node-problem-detector/ — node health (NodeConditions + Events), adapted to Talos
node-problem-detector (NPD): a
DaemonSet that runs on every node (control planes included), reads the kernel log and
reports low-level problems to Kubernetes as NodeConditions and Events.
Directly motivated by the cp2 incident (frozen guest): NPD would have produced a
TaskHung/OOMKilling event visible through kubectl, instead of an opaque bare NotReady.
NPD keeps these two conditions continuously up to date on every node (KernelDeadlock,
ReadonlyFilesystem, False in normal times): that is what you alert on.
kernel-monitor only (/config/kernel-monitor.json, reading /dev/kmsg). The chart loads
kernel-monitor + docker-monitor by default, and the remaining upstream monitors rely on
journald: neither docker nor systemd exists on Talos (no /var/log/journal) → they fail.
So values.yaml reduces settings.log_monitors to kernel-monitor alone.
Namespace in PodSecurity privileged: NPD runs privileged (access to /dev/kmsg),
which the Talos cluster default baseline rejects. The script labels the namespace.
NoSchedule/Exists tolerations → NPD runs on the control planes too (cp1/2/3).
Pinned version: chart deliveryhero/node-problem-detector 2.3.14 (app v0.8.19),
overridable with NPD_VERSION=…. No prerequisites: no storage, no Gateway, no CRD.
Injecting a test kmsg entry on a node triggers the NPD event — you need a privileged pod on the
node (Talos has no shell):
# from a privileged pod that has /dev/kmsg:echo"task test:1234 blocked for more than 120 seconds.">/dev/kmsg# -> event TaskHung
kubectlgetevents-A--field-selectorreason=TaskHung
NPD exposes Prometheus metrics on :20257 (metrics.enabled: true), but the ServiceMonitor is
off: the CRD only exists after ../observability/. Once that component is installed, set
metrics.serviceMonitor.enabled: true in values.yaml and re-run the script →
problem_counter / problem_gauge counters per problem type.
settings.custom_monitors: [] in values.yaml does NOTHING. That key does not exist
in chart 2.3.14: the real keys are settings.custom_monitor_definitions (a map of config files
mounted into /custom-config) and settings.custom_plugin_monitors (a list, empty by
default). Helm silently accepts an unknown value → no error, no effect. The comment in the file
makes it look like an explicit opt-out; in reality the plugin monitors are already empty by
default, and it is log_monitors (very much real) that does all the Talos adaptation work.
The KernelDeadlock condition will (almost) never go True on Talos. In
kernel-monitor.json (v0.8.19) the only permanent rule that raises it is the pattern
task docker:\w+ blocked for more than \w+ seconds — a docker process, which does not
exist on Talos (containerd). A real hang shows up as a TaskHung event (temporary rule,
any process), not as a node condition: alert on the Events, not only on KernelDeadlock.
ReadonlyFilesystem, on the other hand, works normally
(Remounting filesystem read-only).
NPD fixes nothing: it makes things visible. The remedy (cordon/drain, reschedule, reboot,
auto-remediation) is left to the operator, or to a tool like Draino / Descheduler.
A "total" freeze can slip under the radar. As on cp2, a guest that freezes without writing
anything to kmsg leaves no trace: NPD mainly helps with OOM / I/O / FS / task-hung
failures, which do leave a kernel line behind.
Writing to /dev/kmsg to test it requires a privileged pod: on Talos there is neither
SSH nor a shell on the node, you cannot "just" log in to inject the line.
🩺node-problem-detector/ — santé des nodes (NodeConditions + Events), adapté Talos
node-problem-detector (NPD) : un
DaemonSet qui tourne sur chaque node (control-plane inclus), lit le journal du noyau et
remonte les problèmes bas-niveau à Kubernetes en NodeConditions et Events.
Directement motivé par l'incident cp2 (guest figé) : NPD aurait produit un event
TaskHung/OOMKillingvisible via kubectl, au lieu d'un simple NotReady opaque.
NPD maintient en continu ces deux conditions sur chaque node (KernelDeadlock,
ReadonlyFilesystem, à False en temps normal) : c'est dessus qu'on alerte.
kernel-monitor seulement (/config/kernel-monitor.json, lecture de /dev/kmsg). Le
chart charge par défaut kernel-monitor + docker-monitor, et les moniteurs upstream
restants reposent sur journald : ni docker ni systemd n'existent sur Talos (pas de
/var/log/journal) → ils échouent. values.yaml réduit donc settings.log_monitors au seul
kernel-monitor.
Namespace en PodSecurity privileged : NPD tourne en privileged (accès /dev/kmsg),
refusé par le défaut cluster Talos baseline. Le script labellise le namespace.
TolérationsNoSchedule/Exists → NPD tourne aussi sur les control-plane (cp1/2/3).
Version épinglée : chart deliveryhero/node-problem-detector 2.3.14 (app v0.8.19),
surchargeable par NPD_VERSION=…. Aucun prérequis : ni stockage, ni Gateway, ni CRD.
Injecter une entrée kmsg de test sur un node déclenche l'event NPD — il faut un pod privilégié
sur le node (Talos n'a pas de shell) :
# depuis un pod privilégié qui a /dev/kmsg :echo"task test:1234 blocked for more than 120 seconds.">/dev/kmsg# -> event TaskHung
kubectlgetevents-A--field-selectorreason=TaskHung
NPD expose des métriques Prometheus sur :20257 (metrics.enabled: true), mais le
ServiceMonitor est coupé : le CRD n'existe qu'après ../observability/. Une fois cet addon
installé, passer metrics.serviceMonitor.enabled: true dans values.yaml et relancer le
script → compteurs problem_counter / problem_gauge par type de problème.
settings.custom_monitors: [] dans values.yaml ne fait RIEN. Cette clé n'existe pas
dans le chart 2.3.14 : les vraies clés sont settings.custom_monitor_definitions (map de
fichiers de config montés dans /custom-config) et settings.custom_plugin_monitors (liste,
vide par défaut). Helm accepte silencieusement une valeur inconnue → aucune erreur, aucun
effet. Le commentaire du fichier laisse croire à une désactivation explicite ; en réalité les
plugin-monitors sont déjà vides par défaut, et c'est log_monitors (bien réel) qui fait
tout le travail d'adaptation Talos.
La condition KernelDeadlock ne passera (presque) jamais à True sur Talos. Dans
kernel-monitor.json (v0.8.19) la seule règle permanente qui la déclenche est le motif
task docker:\w+ blocked for more than \w+ seconds — un processus docker, qui n'existe
pas sur Talos (containerd). Un hang réel remontera en event TaskHung (règle temporaire,
tous processus confondus), pas en condition de node : alerter sur les Events, pas
seulement sur KernelDeadlock. ReadonlyFilesystem, lui, fonctionne normalement
(Remounting filesystem read-only).
NPD ne corrige rien : il rend visible. Le remède (cordon/drain, reschedule, reboot,
auto-remédiation) reste à l'opérateur, ou à un outil type Draino / Descheduler.
Un gel « total » peut passer sous le radar. Comme sur cp2, un guest qui fige sans rien
écrire dans kmsg ne produit aucune trace : NPD aide surtout sur les pannes OOM / I/O / FS /
task-hung, qui, elles, laissent une ligne noyau.
Écrire dans /dev/kmsg pour tester nécessite un pod privilégié : sur Talos il n'y a ni
SSH ni shell sur le node, on ne peut pas « juste » se connecter pour injecter la ligne.
🐒chaos-kube/ — chaos engineering (chaoskube 0.39) on Talos
Deletes one random pod per hour, everywhere except kube-system,
longhorn-system, vault and cnpg-demo. The point is not to break things: it is to prove that what the lab runs
comes back on its own. Anything that does not come back was never really highly
available.
The lightest addon of the lab — no storage, no Gateway, no certificate.
Prerequisite
Why
Verify
Cluster Ready, KUBECONFIG set
the script probes /readyz
kubectl get nodes
helm in PATH
install goes through the upstream chart
helm version
Something worth killing
on an empty cluster chaoskube has nothing to prove
kubectl get deploy -A
ℹ️ The chart creates its own ServiceAccount + ClusterRole (pods: list, delete and
events: create, cluster-wide). Broad by nature — that is the tool's job.
Idempotent (helm upgrade --install), re-runnable. Two knobs:
CHAOS_DRY_RUN=1./_k8s/chaos-kube/chaoskube-up.sh# observe only, deletes nothingCHAOSKUBE_VERSION=0.6.0./…# pin another chart version
The script ends by reading the flags back out of the Deployment rather than echoing
values.yaml: that is the only proof the exclusions and --no-dry-run actually landed in the
pod.
--namespaces takes a selector-like list where !ns means exclude, comma-separated. The four
exclusions of this lab are not arbitrary:
kube-system — with a single control plane, killing kube-apiserver, etcd, coredns
or the Cilium agent takes down the cluster, not an application. There is no HA to test
there.
longhorn-system — the CSI carries the volumes of everything else. Killing an
instance-manager yanks volumes that are still mounted elsewhere; the damage lands on
innocent bystanders instead of the pod under test.
vault — Raft survives losing a pod, but the pod comes back sealed and this lab has
no auto-unseal. After a few hours all 3 are sealed and Vault is down: that stops being a
resilience test and becomes a chore (../vault-cluster/vault-up.sh, every time).
cnpg-demo — the demo Postgres cluster (Cluster/pg-demo, see
../cloudnative-pg/cluster-demo.yaml). Note this is the cluster's namespace, not the
operator's: cnpg-system stays fair game, since killing the operator interrupts no data path.
Everything else is a target, including envoy-gateway-system — since #62 the data plane runs
2 replicas, so a kill there costs nothing (the controller restarts, the other envoy keeps
serving).
chaoskube ships dry-run on: without --no-dry-run it logs would kill … forever and
deletes nothing. values.yaml therefore carries no-dry-run: "" — an empty value, because the
chart renders --<key> for any key whose value is empty.
Going back to dry-run means removing that key, not setting it to false: the chart template
emits --<key> for any falsy value, so --set chaoskube.args.no-dry-run=null leaves the flag in
place (verified with helm template), and --no-dry-run=false is not a chaoskube flag. That is
why CHAOS_DRY_RUN=1 renders the values into a temporary file with the line stripped.
# dryRun=false is the line that matters — dryRun=true means it is only talking
kubectl-nchaos-kubelogsdeploy/chaoskube|head-5
# the parsed filters, as chaoskube understood them (not as you wrote them)
kubectl-nchaos-kubelogsdeploy/chaoskube|grep'setting pod filter'# the body count, most recent last
kubectlgetevents-A--field-selectorreason=Killing--sort-by=.lastTimestamp
Expected on startup: dryRun=false interval=1h0m0s, then
namespaces="!cnpg-demo,!kube-system,!longhorn-system,!vault" — chaoskube sorts them, so the
order will not match values.yaml — then a first terminating pod: it kills once at startup,
then every hour.
No UI, no HTTPRoute, no domain: chaoskube is a control loop, it exposes nothing. It is
observed through its logs and the Killing Events it writes on its victims.
Interface
Command
Live log
kubectl -n chaos-kube logs -f deploy/chaoskube
Victims
kubectl get events -A --field-selector reason=Killing --sort-by=.lastTimestamp
Pause it without uninstalling — the fastest way to stop the bleeding during a debug session:
Un-excluding vault seals it. It is excluded by default for that reason: Vault survives
losing a pod (Raft, 3 replicas), but the restarted pod is sealed and stays 0/1 — no
auto-unseal here. Drop !vault from the list and within a few hours all three are sealed and
Vault is down; recovering means re-running ../vault-cluster/vault-up.sh after every kill.
Excluding a namespace that does not exist is silently fine.cnpg-demo is only there once
../cloudnative-pg/ is installed; chaoskube just filters on a name and never complains. So
the exclusion can be pre-loaded before the addon exists — which is exactly the case here.
Dry-run is the upstream default — the #1 way to believe chaos is running when nothing is
being deleted. Check dryRun=false in the logs, not the manifest.
A bare pod never comes back. chaoskube deletes pods, it does not care what owns them.
Anything created with a plain kubectl run / a Pod manifest is gone for good — which is
precisely the finding, not a bug.
chaoskube can kill itself: its own namespace is not excluded. The Deployment recreates it,
but the hourly timer restarts from zero, so a self-kill silently skips a round.
Single control plane: kube-system is excluded for that reason. Do not remove that
exclusion on this topology (CONTROL_PLANES=1 in lab.env) — there is no second API server
to take over.
minimum-age is not set, so a pod that has just started is a valid target, including one
in the middle of a rollout. Set minimum-age: "1h" in values.yaml to only ever hit
workloads that have settled.
The timezone is Pacific/Noumea, matching the lab's LAB_DOMAIN (*.ops.nc). It only
affects log timestamps as long as no excluded-weekdays / excluded-times-of-day is set —
add either of those and the timezone becomes load-bearing.
🐒chaos-kube/ — chaos engineering (chaoskube 0.39) sur Talos
Supprime un pod au hasard par heure, partout sauf kube-system,
longhorn-system, vault et cnpg-demo. Le but n'est pas de casser : c'est de prouver que ce que fait tourner le
lab revient tout seul. Ce qui ne revient pas n'a jamais été vraiment hautement
disponible.
L'addon le plus léger du lab — pas de stockage, pas de Gateway, pas de certificat.
Prérequis
Pourquoi
Vérifier
Cluster Ready, KUBECONFIG posé
le script teste /readyz
kubectl get nodes
helm dans le PATH
l'install passe par le chart amont
helm version
Quelque chose à tuer
sur un cluster vide, chaoskube n'a rien à prouver
kubectl get deploy -A
ℹ️ Le chart crée son ServiceAccount + son ClusterRole (pods : list, delete et
events : create, à l'échelle du cluster). Large par nature — c'est le métier de l'outil.
Idempotent (helm upgrade --install), relançable. Deux molettes :
CHAOS_DRY_RUN=1./_k8s/chaos-kube/chaoskube-up.sh# observation seule, ne supprime rienCHAOSKUBE_VERSION=0.6.0./…# épingler une autre version de chart
Le script termine en relisant les flags depuis le Deployment plutôt qu'en réaffichant
values.yaml : c'est la seule preuve que les exclusions et --no-dry-run ont bien atterri dans
le pod.
--namespaces prend une liste façon sélecteur où !ns veut dire exclure, séparée par des
virgules. Les quatre exclusions de ce lab ne sont pas arbitraires :
kube-system — avec un seul control plane, tuer kube-apiserver, etcd, coredns ou
l'agent Cilium fait tomber le cluster, pas une application. Il n'y a aucune HA à tester là.
longhorn-system — le CSI porte les volumes de tout le reste. Tuer un instance-manager
arrache des volumes encore montés ailleurs ; les dégâts tombent sur des innocents plutôt que
sur le pod testé.
vault — le Raft survit à la perte d'un pod, mais le pod revient scellé et ce lab n'a
pas d'auto-unseal. En quelques heures les 3 sont scellés et Vault tombe : ça ne teste plus la
résilience, ça devient une corvée (../vault-cluster/vault-up.sh, à chaque fois).
cnpg-demo — le cluster Postgres de démo (Cluster/pg-demo, cf.
../cloudnative-pg/cluster-demo.yaml). C'est bien le namespace du cluster, pas celui de
l'opérateur : cnpg-system reste du gibier, tuer l'opérateur ne coupe aucun chemin de donnée.
Tout le reste est une cible, y compris envoy-gateway-system — depuis #62 le plan de données
tourne à 2 réplicas, donc un kill n'y coûte rien (le contrôleur redémarre, l'autre envoy
continue de servir).
chaoskube est livré dry-run activé : sans --no-dry-run il logue would kill … à l'infini
et ne supprime rien. values.yaml porte donc no-dry-run: "" — une valeur vide, parce que le
chart rend --<clé> pour toute clé dont la valeur est vide.
Repasser en dry-run demande de retirer la clé, pas de la mettre à false : le template du
chart émet --<clé> dès que la valeur est fausse, donc --set chaoskube.args.no-dry-run=null
laisse le flag en place (vérifié au helm template), et --no-dry-run=false n'est pas un flag
chaoskube. D'où le CHAOS_DRY_RUN=1 qui rend les values dans un fichier temporaire, la ligne en
moins.
# dryRun=false est LA ligne qui compte — dryRun=true veut dire qu'il ne fait que parler
kubectl-nchaos-kubelogsdeploy/chaoskube|head-5
# les filtres tels que chaoskube les a COMPRIS (pas tels que tu les as écrits)
kubectl-nchaos-kubelogsdeploy/chaoskube|grep'setting pod filter'# le tableau de chasse, le plus récent en dernier
kubectlgetevents-A--field-selectorreason=Killing--sort-by=.lastTimestamp
Attendu au démarrage : dryRun=false interval=1h0m0s, puis
namespaces="!cnpg-demo,!kube-system,!longhorn-system,!vault" — chaoskube les trie, l'ordre ne
correspondra donc pas à values.yaml — puis un premier terminating pod : il tue une fois au
démarrage, puis toutes les heures.
Pas d'UI, pas d'HTTPRoute, pas de domaine : chaoskube est une boucle de contrôle, il
n'expose rien. On l'observe par ses logs et par les Events Killing qu'il écrit sur ses
victimes.
Interface
Commande
Log en direct
kubectl -n chaos-kube logs -f deploy/chaoskube
Victimes
kubectl get events -A --field-selector reason=Killing --sort-by=.lastTimestamp
Le mettre en pause sans désinstaller — le moyen le plus rapide d'arrêter l'hémorragie pendant
une session de debug :
Retirer vault de l'exclusion le scelle. Il est exclu par défaut pour cette raison :
Vault survit à la perte d'un pod (Raft, 3 réplicas), mais le pod redémarré est scellé et reste
0/1 — pas d'auto-unseal ici. Enlève !vault de la liste et en quelques heures les trois sont
scellés et Vault tombe ; s'en sortir demande de relancer ../vault-cluster/vault-up.sh après
chaque kill.
Exclure un namespace qui n'existe pas ne pose aucun problème.cnpg-demo n'apparaît
qu'une fois ../cloudnative-pg/ installé ; chaoskube ne filtre que sur un nom et ne se plaint
jamais. L'exclusion peut donc être posée avant l'addon — c'est exactement le cas ici.
Le dry-run est le défaut amont — la première façon de croire que le chaos tourne alors que
rien n'est supprimé. Vérifier dryRun=false dans les logs, pas dans le manifeste.
Un pod nu ne revient jamais. chaoskube supprime des pods, il ne regarde pas qui les
possède. Tout ce qui a été créé avec un simple kubectl run / un manifeste Pod est perdu —
ce qui est précisément le constat, pas un bug.
chaoskube peut se tuer lui-même : son propre namespace n'est pas exclu. Le Deployment le
recrée, mais le minuteur horaire repart de zéro : un auto-kill saute donc silencieusement un
tour.
Un seul control plane : c'est la raison de l'exclusion de kube-system. Ne pas retirer
cette exclusion sur cette topologie (CONTROL_PLANES=1 dans lab.env) — il n'y a pas de
second API server pour prendre le relais.
minimum-age n'est pas posé, donc un pod qui vient de démarrer est une cible valide, y
compris en plein rollout. Mettre minimum-age: "1h" dans values.yaml pour ne toucher que
des charges stabilisées.
Le fuseau est Pacific/Noumea, aligné sur le LAB_DOMAIN du lab (*.ops.nc). Il n'agit
que sur l'horodatage des logs tant qu'aucun excluded-weekdays / excluded-times-of-day
n'est posé — ajoute l'un des deux et le fuseau devient structurant.
Kyverno = an admission webhook + background controllers. Three verbs: validate
(accept / reject / audit), mutate (rewrite the incoming resource), generate (create a
derived resource). Verdicts land in PolicyReport objects, aggregated by Policy Reporter
into a web UI at kyverno.talos.lab.example.io.
🌐 talos.lab.example.io is the repo's NEUTRAL domain (public): kyverno-up.sh swaps it
for LAB_DOMAIN (lab.env) at kubectl apply time. See
../README.md.
Versions pinned in the script: chart kyverno/kyverno3.8.2 (app v1.18.2) and
policy-reporter/policy-reporter3.8.1 (KYVERNO_VERSION / POLICY_REPORTER_VERSION
can be overridden). Idempotent (helm upgrade --install + kubectl apply).
The decisive choice in this lab: evaluation is fail-open#
values.yaml sets features.forceFailurePolicyIgnore.enabled: true. The webhooks that
evaluate your resources therefore move to failurePolicy: Ignore — you can read it in their
names: validate.kyverno.svc-ignore, mutate.kyverno.svc-ignore.
Situation
Consequence
Kyverno unreachable (pod down, timeout)
the request goes through anyway — no "Kyverno down ⇒ no pod ever starts" deadlock
Kyverno reachable, policy in Audit
the request goes through, a fail is written to the PolicyReport
Kyverno reachable, policy in Enforce
the request is rejected (fail-open does not disarm blocking)
ℹ️ Kyverno's internal webhooks (validation of ClusterPolicy and PolicyException,
cleanup) stay on failurePolicy: Fail: only the evaluation of your own resources is fail-open.
Check it yourself:
The background scan evaluates what already runs as soon as it is installed. In the UI:
violations per namespace, per policy, per severity. Nothing was blocked — this is audit. Start
with require-requests-limits and require-labels: they are the noisiest, and that is
instructive (see ⚠️ Pitfalls, "the repo violates its own policies").
kubectlpatchclusterpolicydisallow-latest-tag--typemerge\-p'{"spec":{"validationFailureAction":"Enforce"}}'
kubectlrunbad--image=nginx:latest# REJECTED by the Kyverno webhook
kubectlpatchclusterpolicydisallow-latest-tag--typemerge\-p'{"spec":{"validationFailureAction":"Audit"}}'
⚠️ spec.validationFailureAction is deprecated on this Kyverno (v1.18.2):
kubectl explain clusterpolicy.spec.validationFailureAction answers "Deprecated, use
validationFailureAction under the validate rule instead". The field still works (the repo's 4
policies use it at the spec level), but the modern form is
spec.rules[].validate.failureAction. Nothing to fix for the demo; worth knowing before a
major Kyverno version bump.
longhorn-system MUST run privileged → it shows up as fail on
disallow-privileged-containers. In the real world you exempt it cleanly. PolicyException
objects are disabled by default in the chart; for the demo, reinstall with
--set features.policyExceptions.enabled=true --set features.policyExceptions.namespace=kyverno,
then create a PolicyException targeting longhorn-system. Otherwise, just show the fail in
the UI as an illustration of the need.
Nothing in the UI → aggregation takes ~30 s. kubectl get policyreport -A should already
list rows; if not, check that policy-reporter-ui and policy-reporter-kyverno-plugin are
Running (kubectl -n kyverno get pods).
404 / route not attached → kubectl -n kyverno describe httproute policy-reporter-ui
(sectionName: https on main-gateway, hostname covered by the wildcard).
clusterpolicy stuck at READY=False → kubectl describe clusterpolicy <name>: usually a
pattern error, or a CRD missing on the generator side.
"System components are never subject to policies" is FALSE. The chart does exclude
kube-system, kube-public, kube-node-lease and kyverno via config.resourceFilters
— but that filter applies to admission. The background scan evaluates those resources
anyway and produces reports: on this lab, dozens of fail results in kube-system
(cilium, kube-proxy…) and inside kyverno itself. Check it:
kubectl -n kube-system get policyreport. Practical conclusion: no risk of blocking a
system component, but the reports do include them. (The comment in values.yaml is too
categorical on this point.)
The repo violates its own policies — deliberately, but it is noisy:
03-require-requests-limits requires limits.cpu, which no manifest in the repo sets
(deliberate choice: cap memory, do not throttle CPU). Result: by far the most-failing
policy, and it drowns the others in the UI.
01-require-labels requires app.kubernetes.io/name while every in-house manifest uses
app: → a systematic fail on the lab demos.
02-disallow-latest-tag only inspects spec.containers: a :latest inside
initContainers slips through unnoticed. A good policy-extension exercise.
UI with no authentication. Policy Reporter is published over HTTPS with no auth: anyone who
reaches VIP .200 (any authorized Tailscale peer) reads the cluster's inventory of weaknesses.
To protect it: an Envoy Gateway SecurityPolicy (Basic Auth / OIDC) on the route.
A legitimate Pod rejected after a switch to Enforce → put the policy back to Audit
(scenario 2) or create a PolicyException. Never leave a badly calibrated Enforce policy on
a shared cluster.
1 replica for the admission-controller = an accepted SPOF (lab). Fail-open avoids the
deadlock, but during a restart the policies simply stop applying. For robustness:
admissionController.replicas: 3 in values.yaml (costs RAM).
kubectldelete-f_k8s/kyverno/httproute.yaml
kubectldelete-f_k8s/kyverno/policies/
helm-nkyvernouninstallpolicy-reporter
helm-nkyvernouninstallkyverno# also removes the CRDs → deletes the PolicyReports
kubectldeletenskyverno
⚠️ If ../trivy-operator/ is installed, it loses its UI:
Policy Reporter (namespace kyverno) is what hosts it.
Kyverno = un webhook d'admission + des contrôleurs de fond. Trois verbes : validate
(accepter / refuser / auditer), mutate (réécrire la ressource entrante), generate (créer
une ressource dérivée). Les verdicts atterrissent dans des PolicyReport, agrégés par
Policy Reporter dans une UI web sous kyverno.talos.lab.example.io.
🌐 talos.lab.example.io est le domaine NEUTRE du dépôt (public) : kyverno-up.sh le
remplace par LAB_DOMAIN (lab.env) au moment du kubectl apply. Cf.
../LISEZ-MOI.md.
Le choix décisif de ce lab : l'évaluation est fail-open#
values.yaml pose features.forceFailurePolicyIgnore.enabled: true. Les webhooks qui
évaluent tes ressources passent donc en failurePolicy: Ignore — on le voit dans leurs
noms : validate.kyverno.svc-ignore, mutate.kyverno.svc-ignore.
Situation
Conséquence
Kyverno injoignable (pod down, timeout)
la requête passe quand même — pas de deadlock « Kyverno KO ⇒ plus aucun pod ne démarre »
Kyverno joignable, policy en Audit
la requête passe, un fail est écrit dans le PolicyReport
Kyverno joignable, policy en Enforce
la requête est refusée (le fail-open ne désarme pas le blocage)
ℹ️ Les webhooks internes de Kyverno (validation des ClusterPolicy, des
PolicyException, du cleanup) restent en failurePolicy: Fail : seule l'évaluation de tes
ressources est fail-open. À vérifier soi-même :
Le background scan évalue l'existant dès l'installation. Dans l'UI : violations par
namespace, par policy, par sévérité. Rien n'a été bloqué — c'est de l'audit. Commence par
require-requests-limits et require-labels : ce sont les plus bruyantes, et c'est
instructif (cf. ⚠️ Pièges, « le dépôt viole ses propres policies »).
kubectlpatchclusterpolicydisallow-latest-tag--typemerge\-p'{"spec":{"validationFailureAction":"Enforce"}}'
kubectlrunbad--image=nginx:latest# REFUSÉ par le webhook Kyverno
kubectlpatchclusterpolicydisallow-latest-tag--typemerge\-p'{"spec":{"validationFailureAction":"Audit"}}'
⚠️ spec.validationFailureAction est déprécié sur ce Kyverno (v1.18.2) :
kubectl explain clusterpolicy.spec.validationFailureAction répond « Deprecated, use
validationFailureAction under the validate rule instead ». Le champ fonctionne encore (les 4
policies du dépôt l'utilisent au niveau spec), mais la forme moderne est
spec.rules[].validate.failureAction. Rien à corriger pour la démo ; à savoir avant de
monter Kyverno de version majeure.
longhorn-system DOIT tourner en privilégié → il apparaît en fail sur
disallow-privileged-containers. En vrai, on l'exempte proprement. Les PolicyException sont
désactivées par défaut dans le chart ; pour la démo, réinstalle avec
--set features.policyExceptions.enabled=true --set features.policyExceptions.namespace=kyverno,
puis crée une PolicyException ciblant longhorn-system. Sinon, montre simplement le fail
dans l'UI comme illustration du besoin.
Rien dans l'UI → l'agrégation prend ~30 s. kubectl get policyreport -A doit déjà lister
des lignes ; sinon vérifie que policy-reporter-ui et policy-reporter-kyverno-plugin sont
Running (kubectl -n kyverno get pods).
404 / route non rattachée → kubectl -n kyverno describe httproute policy-reporter-ui
(sectionName: https sur main-gateway, hostname couvert par le wildcard).
clusterpolicy en READY=False → kubectl describe clusterpolicy <nom> : souvent une
erreur de pattern ou une CRD manquante côté générateur.
« Les composants système ne sont jamais soumis aux policies » est FAUX. Le chart exclut
bien kube-system, kube-public, kube-node-lease et kyverno via config.resourceFilters
— mais ce filtre porte sur l'admission. Le scan de fond évalue quand même ces
ressources et produit des rapports : sur ce lab, des dizaines de fail en kube-system
(cilium, kube-proxy…) et dans kyverno lui-même. Vérifie-le :
kubectl -n kube-system get policyreport. Conclusion pratique : aucun risque de bloquer
un composant système, mais les rapports, eux, les incluent. (Le commentaire de values.yaml
est trop catégorique sur ce point.)
Le dépôt viole ses propres policies — c'est assumé, mais ça fait du bruit :
03-require-requests-limits exige limits.cpu, qu'aucun manifeste du dépôt ne pose
(choix délibéré : on borne la mémoire, on ne throttle pas le CPU). Résultat : c'est de
très loin la policy la plus en échec, et elle noie les autres dans l'UI.
01-require-labels exige app.kubernetes.io/name alors que tous les manifestes maison
utilisent app: → fail systématique sur les démos du lab.
02-disallow-latest-tag ne contrôle que spec.containers : un :latest dans un
initContainers passe inaperçu. Bon exercice d'extension de policy.
UI sans authentification. Policy Reporter est publié en HTTPS sans auth : quiconque
atteint le VIP .200 (tout peer Tailscale autorisé) lit l'inventaire des faiblesses du
cluster. Pour la protéger : SecurityPolicy Envoy Gateway (Basic Auth / OIDC) sur la route.
Un Pod légitime refusé après un passage en Enforce → repasse la policy en Audit
(scénario 2) ou crée une PolicyException. Ne laisse jamais une policy Enforce mal calibrée
sur un cluster partagé.
1 replica pour l'admission-controller = SPOF assumé (lab). Le fail-open évite le blocage,
mais pendant un redémarrage les policies ne s'appliquent simplement plus. Pour de la
robustesse : admissionController.replicas: 3 dans values.yaml (coûte de la RAM).
kubectldelete-f_k8s/kyverno/httproute.yaml
kubectldelete-f_k8s/kyverno/policies/
helm-nkyvernouninstallpolicy-reporter
helm-nkyvernouninstallkyverno# retire aussi les CRD → supprime les PolicyReport
kubectldeletenskyverno
⚠️ Si ../trivy-operator/ est installé, il perd son UI :
c'est Policy Reporter (namespace kyverno) qui l'héberge.
The detective side of the lab's security. Trivy Operator scans what runs, in a loop
(images, configs, secrets, RBAC), and writes its findings into report CRDs. Policy
Reporter's trivy plugin surfaces them in the same UI as Kyverno → a single security
dashboard.
⚠️ The node-collector is incompatible with Talos — THE thing to know here. The last two
scanners go through a node-collector pod that bind-mounts /etc/systemd, /lib/systemd,
/etc/kubernetes… but Talos has no systemd and / + /etc are read-only →
CreateContainerError: mkdir /etc/systemd: read-only file system (and, before that, a
PodSecurity baseline rejection on hostPID/hostPath). Hence, in values.yaml:
infraAssessmentScannerEnabled: false and clusterComplianceEnabled: false. The image /
config / secret / RBAC scans are not affected.
Versions pinned in the script: chart aqua/trivy-operator0.34.0 (app v0.32.0) and
policy-reporter3.8.1 (TRIVY_OPERATOR_VERSION / POLICY_REPORTER_VERSION can be
overridden). Idempotent.
installs Trivy Operator in trivy-system with values.yaml, then waits for the rollout;
if the policy-reporter release exists in kyverno, re-applies it to enable the trivy
plugin (already declared in ../kyverno/policy-reporter-values.yaml); otherwise it says so
and carries on.
After a few minutes, the UI ("trivy" source) or the command above lists the fixable
HIGH/CRITICAL CVEs per image. Move on to the question that matters: which image to update first,
and to which tag.
2. Close the loop, preventive + detective (Kyverno × Trivy)#
Trivy detects an image on :latest or carrying CVEs; Kyverno can prevent its admission
(disallow-latest-tag, or Cosign signature verification). A clean demo of "I observe → I
prevent". The demo apps in ../envoy-gateway/GW-Example.yml make perfect guinea pigs (one of
them is on :latest).
With no ClusterComplianceReport (see the callout), ConfigAuditReport objects remain the best
entry point: they carry the Pod Security-style checks on every workload.
⚠️ The "CIS compliance scan" scenario is not available on this lab.kubectl get clustercompliancereport may list objects (the chart ships the k8s-cis-*,
k8s-nsa-*, k8s-pss-* definitions), but their status is no longer populated since the
compliance controller is disabled. Do not build a demo on it. To audit the Talos nodes, go
through the Talos tooling (talosctl): no pod can inspect /etc or the systemd units, which
do not exist.
📈 Prometheus integration (after the observability addon)#
Trivy Operator exposes metrics (vulnerability counters per workload). Once
kube-prometheus-stack is installed (the ServiceMonitor CRD is present), set
serviceMonitor.enabled: true in values.yaml and re-run the script: the counters become
scrapable and alertable. See ../observability/.
Ghost reports after turning the node-collector off. If Trivy ran before
infraAssessmentScannerEnabled/clusterComplianceEnabled went to false, the
InfraAssessmentReport and ClusterComplianceReport objects already written stay stored,
frozen (observed on this lab). They give the illusion of an active scan. Clean them up if you
want an honest state: kubectl delete infraassessmentreports -A --all.
Scan jobs Pending / OOM → concurrency is already at 1; set
trivy.builtInTrivyServer: true (shared trivy server, cached CVE database) or add RAM.
No reports after 10 min → kubectl -n trivy-system logs deploy/trivy-operator; it is
almost always a job failing to download the CVE database (network, registry, Docker Hub
rate-limit).
Too much noise → trivy.severity and trivy.ignoreUnfixed are the two knobs; conversely,
set severity: "LOW,MEDIUM,HIGH,CRITICAL" for a "show everything" demo.
Scanning eats network and CPU in waves (scannerReportTTL: 30m). On a loaded lab, stretch
the TTL (24h) rather than disabling the operator.
🔎trivy-operator/ — scanner de sécurité continu (Aqua Trivy Operator)
Le volet détectif de la sécurité du lab. Trivy Operator scanne en boucle ce qui tourne
(images, configs, secrets, RBAC) et écrit ses conclusions dans des CRD de rapport. Le
plugin trivy de Policy Reporter les remonte dans la même UI que Kyverno → un seul
tableau de bord sécurité.
⚠️ Le node-collector est incompatible avec Talos — c'est LE point à connaître ici. Les
deux derniers scanners passent par un pod node-collector qui bind-monte /etc/systemd,
/lib/systemd, /etc/kubernetes… Or Talos n'a pas de systemd et / + /etc sont en
lecture seule → CreateContainerError: mkdir /etc/systemd: read-only file system (et,
avant ça, refus PodSecurity baseline sur hostPID/hostPath). D'où, dans values.yaml :
infraAssessmentScannerEnabled: false et clusterComplianceEnabled: false. Les scans
images / config / secrets / RBAC ne sont pas affectés.
installe Trivy Operator dans trivy-system avec values.yaml, puis attend le rollout ;
si la release policy-reporter existe dans kyverno, la réapplique pour activer le
plugin trivy (déjà déclaré dans ../kyverno/policy-reporter-values.yaml) ; sinon il
l'annonce et continue.
Après quelques minutes, l'UI (source « trivy ») ou la commande ci-dessus listent les CVE
HIGH/CRITICAL corrigeables par image. Enchaîne sur la question qui compte : quelle image
mettre à jour en premier, et à quel tag.
Trivy détecte une image en :latest ou porteuse de CVE ; Kyverno peut empêcher son
admission (disallow-latest-tag, ou vérification de signature Cosign). Démonstration nette du
« je constate → j'empêche ». Les apps de démo de ../envoy-gateway/GW-Example.yml font de
parfaits cobayes (l'une est en :latest).
Faute de ClusterComplianceReport (voir l'encart), les ConfigAuditReport restent la meilleure
entrée : ils portent les contrôles de type Pod Security sur chaque workload.
⚠️ Le scénario « scan de conformité CIS » n'est pas disponible sur ce lab.kubectl get clustercompliancereport peut lister des objets (le chart livre les définitions
k8s-cis-*, k8s-nsa-*, k8s-pss-*), mais leur statusn'est plus alimenté puisque le
contrôleur de conformité est désactivé. Ne construis pas de démo dessus. Pour auditer les
nodes Talos, il faut passer par les outils de Talos (talosctl) : aucun pod ne peut
inspecter /etc ni les unités systemd, qui n'existent pas.
Trivy Operator expose des métriques (compteurs de vulnérabilités par workload). Une fois
kube-prometheus-stack installé (CRD ServiceMonitor présent), passe
serviceMonitor.enabled: true dans values.yaml puis relance le script : les compteurs
deviennent scrapables et alertables. Voir ../observability/.
Rapports fantômes après avoir coupé le node-collector. Si Trivy a tourné avant que
infraAssessmentScannerEnabled/clusterComplianceEnabled passent à false, les
InfraAssessmentReport et les ClusterComplianceReport déjà écrits restent en base, figés
(constaté sur ce lab). Ils donnent l'illusion d'un scan actif. À nettoyer si tu veux un état
honnête : kubectl delete infraassessmentreports -A --all.
Jobs de scan en Pending / OOM → la concurrence est déjà à 1 ; passe
trivy.builtInTrivyServer: true (serveur trivy partagé, base CVE en cache) ou ajoute de la RAM.
Pas de rapports après 10 min → kubectl -n trivy-system logs deploy/trivy-operator ;
c'est presque toujours un job qui n'arrive pas à télécharger la base de CVE (réseau, registre,
rate-limit Docker Hub).
Bruit trop important → trivy.severity et trivy.ignoreUnfixed sont les deux
molettes ; à l'inverse, mettre severity: "LOW,MEDIUM,HIGH,CRITICAL" pour une démo « tout voir ».
Le scan consomme du réseau et du CPU par vagues (scannerReportTTL: 30m). Sur un lab
chargé, allonger le TTL (24h) plutôt que de désactiver l'operator.
🐙argocd/ — Argo CD (GitOps) exposed through the Gateway API
The lab's GitOps, over HTTPS, in one command. Argo CD reconciles cluster state with Git
manifests; its UI/API are published at argo.talos.lab.example.io behind the same
main-gateway as the rest of the lab, with the *.talos.lab.example.io wildcard already
issued by cert-manager — nothing new on the certificate side.
🌐 talos.lab.example.io is the repo's NEUTRAL domain (public): argocd-up.sh swaps it
for LAB_DOMAIN (lab.env) in the Helm values and in the HTTPRoute. See
../README.md.
Run a full GitOps cycle: repo → Application → sync → drift detected → resync.
Show one more cross-namespace Gateway API attachment (route in argocd, Gateway in
envoy-gateway-system).
Serve as a playground for deploying the other addons through Git instead of kubectl.
ℹ️ Standalone addon: Argo CD is not installed by ../platform-up.sh (which only
lays down Cilium + Envoy Gateway + metrics-server + cert-manager). It installs on demand, like
../longhorn/, ../vault-cluster/, ../kyverno/…
Envoy terminates TLS, argocd-server speaks plaintext. We set server.insecure=true: HTTPS
in front (wildcard cert), HTTP behind. Without it, argocd-server would issue its own
307 http→https redirect while the proxy already terminates TLS → redirect loop. This is
the recommended mode behind an ingress/gateway that handles TLS.
Chart argo/argo-cd10.2.1 (app v3.4.5), pinned in the script via ARGOCD_VERSION
(can be overridden). Idempotent (helm upgrade --install + kubectl apply).
Manual equivalent
helmrepoaddargohttps://argoproj.github.io/argo-helm&&helmrepoupdate
# --version: keep the one from the script (ARGOCD_VERSION)
helmupgrade--installargocdargo/argo-cd\--namespaceargocd--create-namespace\--version10.2.1\--values_k8s/argocd/values.yaml
kubectl-nargocdrolloutstatusdeploy/argocd-server
kubectlapply-f_k8s/argocd/httproute.yaml
The route lives in argocd and attaches to main-gateway (ns envoy-gateway-system) thanks to
allowedRoutes.namespaces.from: All on the Gateway side; since the backend sits in the same
namespace as the route, no ReferenceGrant is needed.
Redirect loop / too many redirects → server.insecure is not active:
kubectl -n argocd get cm argocd-cmd-params-cm -o jsonpath='{.data.server\.insecure}' must
return "true", then kubectl -n argocd rollout restart deploy/argocd-server.
404 / route not attached → kubectl -n argocd describe httproute argocd-server:
sectionName: https must exist on main-gateway, and the hostname must match the wildcard.
Untrusted certificate → does the https listener really serve
wildcard-talos-lab-example-io-tls? (see ../cert-manager/README.md).
UI fine but argocd login failing → add --grpc-web.
The argocd-initial-admin-secret Secret stays in the cluster in plaintext as long as you
have not deleted it: it is a full admin credential, readable by anything holding get secrets
in argocd.
UI published on the VIP: Argo CD has its own authentication (unlike the Longhorn UI), but it
stays reachable by every authorized Tailscale peer. A strong password is mandatory.
Dex disabled = no SSO: only the local admin exists. Re-enable dex.enabled if you want to
wire up an IdP (costs a pod).
Argo CD can fight with kubectl: hand a component over to an Application with
selfHeal: true and every manual kubectl edit gets reverted. Pick your deployment mode.
Stacking this component on control planes that are too tight on RAM ends up starving etcd.
lab.env (CP_MEM) is the knob; see the pitfalls in ../README.md.
🐙argocd/ — Argo CD (GitOps) exposé via la Gateway API
Le GitOps du lab, en HTTPS, en une commande. Argo CD réconcilie l'état du cluster avec des
manifestes Git ; son UI/API sont publiées sous argo.talos.lab.example.io derrière le même
main-gateway que le reste du lab, avec le wildcard *.talos.lab.example.io déjà émis par
cert-manager — rien de neuf côté certificat.
🌐 talos.lab.example.io est le domaine NEUTRE du dépôt (public) : argocd-up.sh le
remplace par LAB_DOMAIN (lab.env) dans les values Helm et l'HTTPRoute. Cf.
../LISEZ-MOI.md.
Dérouler un cycle GitOps complet : dépôt → Application → sync → drift détecté → resync.
Montrer un rattachement Gateway API inter-namespace de plus (route dans argocd, Gateway
dans envoy-gateway-system).
Servir de terrain de jeu pour déployer les autres addons par Git au lieu de kubectl.
ℹ️ Addon à part : Argo CD n'est pas installé par ../platform-up.sh (qui ne pose que
Cilium + Envoy Gateway + metrics-server + cert-manager). Il s'installe à la demande, comme
../longhorn/, ../vault-cluster/, ../kyverno/…
Envoy termine le TLS, argocd-server parle en clair. On règle server.insecure=true : HTTPS
devant (cert wildcard), HTTP derrière. Sans ça, argocd-server ferait sa propre redirection
307 http→https alors que le proxy termine déjà le TLS → boucle de redirection. C'est le
mode recommandé derrière un ingress/gateway qui gère le TLS.
La route vit dans argocd et s'attache à main-gateway (ns envoy-gateway-system) grâce à
allowedRoutes.namespaces.from: All côté Gateway ; le backend étant dans le même namespace que
la route, aucun ReferenceGrant n'est nécessaire.
Boucle de redirection / too many redirects → server.insecure n'est pas actif :
kubectl -n argocd get cm argocd-cmd-params-cm -o jsonpath='{.data.server\.insecure}' doit
valoir "true", puis kubectl -n argocd rollout restart deploy/argocd-server.
404 / route non rattachée → kubectl -n argocd describe httproute argocd-server :
sectionName: https doit exister sur main-gateway, et le hostname matcher le wildcard.
Certificat non trusté → l'écouteur https sert-il bien wildcard-talos-lab-example-io-tls ?
(cf. ../cert-manager/LISEZ-MOI.md).
Le Secret argocd-initial-admin-secret reste en clair dans le cluster tant que tu ne l'as
pas supprimé : c'est un identifiant admin complet, lisible par tout ce qui a le droit get secrets dans argocd.
UI publiée sur le VIP : Argo CD a sa propre authentification (contrairement à l'UI
Longhorn), mais reste joignable par tout peer Tailscale autorisé. Mot de passe fort obligatoire.
Dex désactivé = pas de SSO : seul l'admin local existe. Réactiver dex.enabled si tu veux
brancher un IdP (coûte un pod).
Argo CD peut se battre avec kubectl : si tu confies un addon à une Application avec
selfHeal: true, tout kubectl edit manuel sera annulé. Choisis ton mode de déploiement.
Empiler cet addon sur des control planes trop justes en RAM finit par affamer etcd.
lab.env (CP_MEM) est la molette ; voir les pièges de ../LISEZ-MOI.md.
The demo that exercises Longhorn end to end. Two 2Gi PVCs (RWO, StorageClass
longhorn) for MariaDB (/var/lib/mysql) and WordPress (/var/www/html), exposed over
HTTPS through Envoy Gateway at wordpress.talos.lab.example.io. One manifest, no script.
DNS wordpress.talos.lab.example.io → 192.168.56.200 (DNS-only)
hostname of the route
curl --resolve otherwise (see ✅)
⚠️ No safety net: there is no *-up.sh here. The manifest references the longhorn
StorageClass without checking that it exists. Without Longhorn (or with only local-path), the
kubectl applysucceeds and the PVCs stay silently Pending, with the pods stuck in
Pending too. Check kubectl get scfirst. For a demo without Longhorn, replace
storageClassName: longhorn with local-path in both PVCs — accepting that a local-path PV
is node-local and not replicated (see
../local-path-storage/).
Everything sits in this single file, namespace wordpress-test included.
🌐 Domain: the manifest carries the neutral domain talos.lab.example.io (public repo) and
does not go through a *-up.sh: edit the hostname, or substitute your own domain on the fly:
Three places to cover in this manifest: the hostname of the HTTPRouteandWP_HOME/WP_SITEURL (WORDPRESS_CONFIG_EXTRA) — WordPress builds its URLs from those
two constants, and a wrong domain breaks the CSS and the install redirect.
strategy: Recreate — Longhorn volumes are RWO (single-attach): the old pod has to
release the volume before the new one can attach it. With RollingUpdate (the default), the new
pod would stay stuck on multi-attach.
subPath — the database and the site live in a subdirectory of the volume, to avoid the
lost+found that ext4 creates at the root (MariaDB refuses a "non-empty" datadir).
TLS terminated by Envoy — WordPress only ever sees HTTP. We force detection through
HTTP_X_FORWARDED_PROTO and freezeWP_HOME/WP_SITEURL to https://… in
WORDPRESS_CONFIG_EXTRA; otherwise WordPress generates http URLs and loops on redirects.
kubectl-nwordpress-testgetpvc,pods# PVC Bound, pods Running 1/1
curl-sS-o/dev/null-w'%{http_code}\n'\--resolvewordpress.talos.lab.example.io:443:192.168.56.200\https://wordpress.talos.lab.example.io/# 302 → /wp-admin/install.php (fresh WP)# then finish the install in the browser: https://wordpress.talos.lab.example.io/
# 1. Finish the WordPress install in the browser, publish a post.# 2. Kill both pods: their PVCs are reattached on restart.
kubectl-nwordpress-testdeletepod--all
kubectl-nwordpress-testgetpods-w# Recreate: the old one releases, the new one attaches# 3. Reload the page: the post is still there (the data lives in the Longhorn volumes).
On the Longhorn side (UI longhorn.talos.lab.example.io, or kubectl -n longhorn-system get volumes), you watch both volumes detach then reattach — and see which node they are attached to.
Plaintext passwords in the manifest. The mariadb Secret is written with stringData and
example values committed to the repo: this is training material, not a template. Outside
the lab, use a Secret created outside Git (or even
../vault-secret-operator/, which does exactly that).
Changing those values after the first start is not enough: MariaDB only initializes its
credentials when the datadir is created.
PVC Pending with no visible error → the longhorn StorageClass is missing (see
📋 Prerequisites) or Longhorn is down. kubectl -n wordpress-test describe pvc mariadb-data
gives the real reason.
Multi-Attach error → a RollingUpdate slipped in instead of Recreate, or the old pod is
stuck in Terminating (lost node). Force-delete the old pod.
The domain is hardcoded in WORDPRESS_CONFIG_EXTRA (WP_HOME/WP_SITEURL) and in the
HTTPRoute. If you change domain, you have to edit both — otherwise WordPress redirects to the
old name.
wordpress:6.7-php8.3-apache and mariadb:11.4 are release-series tags, not digests: the
content can move under the same tag. Good enough for a lab, not enough for strict
reproducibility.
kubectldelete-f_k8s/wordpress-example/wordpress-mariadb.yaml# deletes the namespace + the PVCs
ℹ️ Deleting the PVCs releases the Longhorn volumes (reclaimPolicy: Delete on the
StorageClass): the data is lost, including the posts published during the demo.
📝wordpress-example/ — WordPress + MariaDB (démo de stockage persistant)
La démo qui exerce Longhorn de bout en bout. Deux PVC de 2Gi (RWO, StorageClass
longhorn) pour MariaDB (/var/lib/mysql) et WordPress (/var/www/html), exposés en HTTPS
via Envoy Gateway sous wordpress.talos.lab.example.io. Un seul manifeste, aucun script.
DNS wordpress.talos.lab.example.io → 192.168.56.200 (DNS-only)
hostname de la route
curl --resolve sinon (cf. ✅)
⚠️ Aucun garde-fou : il n'y a pas de *-up.sh ici. Le manifeste référence la StorageClass
longhorn sans vérifier qu'elle existe. Sans Longhorn (ou avec seulement local-path), le
kubectl applyréussit et les PVC restent silencieusement Pending, pods bloqués en
Pending eux aussi. Vérifie kubectl get scavant. Pour une démo sans Longhorn, remplace
storageClassName: longhorn par local-path dans les deux PVC — en assumant qu'un PV
local-path est node-local et non répliqué (cf.
../local-path-storage/).
Tout est dans ce seul fichier, namespace wordpress-test inclus.
🌐 Domaine : le manifeste porte le domaine neutre talos.lab.example.io (dépôt public)
et n'est pas passé par un *-up.sh : édite le hostname, ou substitue ton domaine à la volée :
Trois endroits à couvrir dans ce manifeste : le hostname de l'HTTPRouteetWP_HOME/WP_SITEURL (WORDPRESS_CONFIG_EXTRA) — WordPress génère ses URL depuis
ces deux constantes, un domaine faux casse le CSS et la redirection d'install.
front, Recreate, subPath: wp, sonde sur /wp-login.php
Service mariadb / wordpress
ClusterIP (3306 / 80)
HTTPRoute wordpress
wordpress.talos.lab.example.io → wordpress:80, sectionName: https de main-gateway
Les deux conteneurs déclarent requests (cpu + memory) et une limite mémoire seulement —
pas de limite CPU (choix du dépôt : borner la RAM, ne pas throttler le CPU).
strategy: Recreate — les volumes Longhorn sont RWO (mono-attach) : l'ancien pod doit
libérer le volume avant que le nouveau l'attache. Avec RollingUpdate (le défaut), le nouveau
pod resterait bloqué en multi-attach.
subPath — la base et le site vivent dans un sous-dossier du volume, pour éviter le
lost+found créé par ext4 à la racine (MariaDB refuse un datadir « non vide »).
TLS terminé par Envoy — WordPress ne voit que du HTTP. On force la détection via
HTTP_X_FORWARDED_PROTO et on figeWP_HOME/WP_SITEURL en https://… dans
WORDPRESS_CONFIG_EXTRA ; sinon WordPress génère des URLs en http et boucle en redirection.
# 1. Termine l'installation WordPress dans le navigateur, publie un article.# 2. Tue les deux pods : leurs PVC sont réattachés au redémarrage.
kubectl-nwordpress-testdeletepod--all
kubectl-nwordpress-testgetpods-w# Recreate : l'ancien libère, le nouveau attache# 3. Recharge la page : l'article est toujours là (les données vivent dans les volumes Longhorn).
Côté Longhorn (UI longhorn.talos.lab.example.io, ou kubectl -n longhorn-system get volumes), on
voit les deux volumes se détacher puis se rattacher — et sur quel node ils sont attachés.
Des mots de passe en clair dans le manifeste. Le Secret mariadb est écrit en
stringData avec des valeurs d'exemple versionnées dans le dépôt : c'est un support de
formation, pas un modèle. Hors lab, passer par un Secret créé hors Git (voire par
../vault-secret-operator/, qui fait exactement ça).
Changer ces valeurs après le premier démarrage ne suffit pas : MariaDB n'initialise ses
identifiants qu'à la création du datadir.
PVC Pending sans erreur visible → StorageClass longhorn absente (voir 📋 Prérequis) ou
Longhorn en panne. kubectl -n wordpress-test describe pvc mariadb-data donne la vraie raison.
Multi-Attach error → un RollingUpdate s'est glissé à la place de Recreate, ou l'ancien
pod est coincé en Terminating (node perdu). Forcer la suppression du vieux pod.
Le domaine est figé en dur dans WORDPRESS_CONFIG_EXTRA (WP_HOME/WP_SITEURL) et
dans l'HTTPRoute. Si tu changes de domaine, il faut modifier les deux — sinon WordPress
redirige vers l'ancien nom.
wordpress:6.7-php8.3-apache et mariadb:11.4 sont des tags de série, pas des digests :
le contenu peut bouger sous le même tag. C'est suffisant pour un lab, insuffisant pour de la
reproductibilité stricte.
kubectldelete-f_k8s/wordpress-example/wordpress-mariadb.yaml# supprime le namespace + les PVC
ℹ️ Supprimer les PVC libère les volumes Longhorn (reclaimPolicy: Delete de la StorageClass) :
les données sont perdues, y compris les articles publiés pendant la démo.
Talos Linux on VirtualBox lab, driven by Vagrant. Talos has neither SSH nor a shell:
everything is driven with talosctl from the host. User docs: README.md ·
application layer: _k8s/README.md.
vagrant up creates/starts the VMs (Talos boots off the ISO in maintenance mode).
./talos/cluster-up.sh generates the config, applies it, bootstraps etcd, fetches the
kubeconfig and waits for health. This is the real path (the <details> in §4 of the README
is the manual "to understand what happens" version).
./_k8s/platform-up.sh lays down the base platform, then the addons, opt-in
(_k8s/*/*-up.sh).
The reference lab runs CNI=cilium — the repo default, in lab.env.example, in
talos/cluster-up.sh and in platform-up.sh. Talos installs no CNI at bootstrap and
platform-up.sh installs Cilium right after; that is what the _k8s/ layer assumes
everywhere (LoadBalancer Services depend on Cilium's L2 announcement). CNI=none produces
the exact same machine config but installs nothing at all — it means "I lay down my own CNI",
and platform-up.sh then stops on no node Ready.
"Install X" is still a repo change first. The deliverable is the reproducible path —
manifests, *-up.sh, README — never a hand-rolled kubectl apply that leaves no trace in
git. Deploying to the lab afterwards is fine and expected: run the *-up.sh you just wrote,
which is also how you find out whether it actually works. What is NOT fine is a cluster
carrying state no script can rebuild.
Ask before anything destructive.vagrant destroy, talosctl reset/upgrade,
regenerating _out/, deleting a PVC or a namespace holding data: these are one-way on a lab
that takes ~15 min to rebuild. Reading is always free (kubectl get, talosctl read, helm show values, helm template) — use it to back up your claims rather than guessing.
One feature = one merged PR. Branch from main, conventional commit, PR, squash merge
(1 commit on main). No big catch-all commit mixing several topics: split by feature, even
if that means several PRs back to back.
✅ Validating a change WITHOUT touching a cluster (do this every time)#
makevalidate# bash -n on every script + YAML parse + vagrant validate + config gen
makedocs# regenerates docs/index.html from every README (needs uv)
make validate-yaml alone parses every git-tracked *.yaml/*.yml (PyYAML pulled in by uv,
so nothing to install). The ci workflow re-runs validate-shell, validate-yaml and
validate-vagrant on every PR through the same make targets — never duplicate a check's
definition in the workflow. A runner has no VirtualBox, hence
make validate-vagrant VAGRANT_VALIDATE_FLAGS=--ignore-provider there.
make validate-talos generates the config in an mktemp -d, then feeds it to talosctl validate --mode metal: neither _out/ nor the cluster is touched. To test a patch against an
existing config without applying it: talosctl machineconfig patch <file> --patch <inline|@file> -o /tmp/x.yaml, then validate.
make docs regenerates the bilingual page and lists, at the end of the build, every *.md
link and cross-file anchor that does not resolve. make validate-docs (included in make validate) builds into a throwaway directory and fails on the first unresolved link —
that is the guard to run after renaming a heading or adding a page.
Do NOT re-run cluster-up.sh against an already-installed cluster: wait_maintenance
polls get disks --insecure, which a node in secure mode never answers. Both waits are
bounded since #53 (WAIT_MAINTENANCE, 300 s; WAIT_SECURE, 600 s — both overridable) and
fail with a message naming the two likely causes, so this no longer hangs forever — it just
wastes the timeout. To grow a running cluster: README §6.1.
Do NOT regenerate _out/ (nor FORCE=1) on a running cluster: new secrets/CA ⇒ broken
cluster. Only regenerate after vagrant destroy.
Addressing: topology and addressing live in lab.env (single source read by both the
Vagrantfile AND talos/cluster-up.sh). Versioned template lab.env.example; lab.env is
gitignored. CP = .10/.20/.30, workers = .101+. A real environment variable still wins
(WORKERS=6 vagrant up).
NETWORK is only half configurable: 192.168.56.x is hardcoded in
talos/patch-all.yaml (validSubnets), talos/patch-cp.yaml (vip.ip,
advertisedSubnets) and talos/cni-flannel.yaml (--iface-can-reach). Changing NETWORK
without editing those three files gives you a silently broken cluster.
Three places carry the Talos version: Vagrantfile (fallback default),
talos/cluster-up.sh (fallback default) and lab.env. Both defaults are now aligned on
v1.13.7 — keep them that way on every bump, and remember that INSTALLER_IMAGE (factory
image, tag included) overrides TALOS_VERSION for what actually lands on disk.
Never lower CP_MEM below 3072: 2 GB control planes starve etcd as soon as _k8s/
addons stack up. The template now ships 4096 (and so does the Vagrantfile fallback),
which observability/ requires. Cost of the default topology: 18 GB of host RAM.
Renaming VMs: destroy (vagrant destroy) BEFORE changing s[:name] in the
Vagrantfile, otherwise the old VMs become orphans in VirtualBox.
vagrant up fails after a destroy (VERR_ALREADY_EXISTS on the temp_clone_…
rename): VirtualBox 7.x leaves orphaned ~/VirtualBox VMs/talos-*/ directories plus dead
entries in the media registry. Cleanup: ./talos/virtualbox-cleanup.sh (idempotent,
DRY_RUN=1 to preview). NEVER on a running cluster — and note that it also deletes
temp_clone_* VMs, including those of another Vagrant project mid-up.
Disk sentinel: the Vagrantfile considers a VM provisioned if
.vagrant/talos-disks/<vm>.vdi exists. A destroy that fails and leaves the .vdi behind
makes the next up create a VM with no disk attached, with an obscure install error.
CNI: CNI=cilium|calico|flannel|none (default cilium) expresses an intent, read in
two places — cluster-up.sh applies talos/cni-<CNI>.yaml, then platform-up.sh installs
the CNI unless Talos already did. Only flannel is laid down by Talos at bootstrap time
(cluster.network.cni); cilium and calico go through cni.name: none then Helm. Any
manual gen config MUST include --config-patch-control-plane @talos/cni-<CNI>.yamland--install-image "$INSTALLER_IMAGE" — without it the classic installer is laid down,
without the iscsi extensions, and Longhorn fails later on iscsiadm: not found.
TLS: SELF_SIGNED=true is the default, and it skips cert-manager entirely.platform-up.sh step [4/4] branches on it: true runs
_k8s/self-signed/selfsigned-up.sh (local CA + openssl wildcard into
_out/self-signed/, then the TLS Secret) and strips the
cert-manager.io/cluster-issuer annotation from main-gateway; false installs
cert-manager as before. Both modes fill the SAME Secret
(wildcard-<LAB_DOMAIN with dashes>-tls), so no addon ever branches on the TLS mode —
keep it that way. LAB_DNS_ZONE, LAB_ACME_EMAIL, LAB_ACME_ISSUER and
CLOUDFLARE_API_TOKEN are dead variables when SELF_SIGNED=true. Switching modes on a
live cluster leaves the other mode's object behind (a Certificate, or a hand-made
Secret) — see _k8s/self-signed/README.md §⚠️.
ACME: staging is the default, and prod has a weekly quota (SELF_SIGNED=false only). LAB_ACME_ISSUER
(staging|prod, default staging) drives the cert-manager.io/cluster-issuer annotation —
the versioned Envoy-Proxy.yml carries letsencrypt-staging, and platform-up.sh rewrites
it. Do NOT switch the repo default back to prod: the wildcard lives only in etcd, so
every vagrant destroy burns one of the 5 certificates/week per identifier set Let's
Encrypt production allows. Already hit on 2026-07-26: 5/5 consumed, 429 rateLimited, no TLS
for 18 h — while the destroyed cert was valid for another 3 months. Before a destroy on a
prod lab: kubectl -n envoy-gateway-system get secret <wildcard>-tls -o yaml > _out/wildcard-tls.backup.yaml (private key inside — _out/ is gitignored).
Calico/tigera-operator: two bootstrap traps, both fixed in _k8s/calico/ — do not undo
them. (1) The chart renders four CRs (Installation, APIServer, Goldmane, Whisker)
but ships no crds/ directory — the operator creates the CRDs at runtime
(-manage-crds=true), so any CR left enabled kills helm install on a fresh cluster with
no matches for kind … ensure CRDs are installed first. All four stay enabled=false; the
ones we want live in installation.yaml / apiserver.yaml, applied after the CRD wait.
(2) The operator needs hostNetwork + a hostPath, which Talos's default baseline
PodSecurity rejects, and helm --create-namespace sets no PSS label ⇒ _k8s/calico/namespace.yaml
must be applied before the chart. Failure mode is nasty: get pods shows zero pod
(not a failing one), the Deployment just never rolls out — the reason is only in
kubectl -n tigera-operator describe rs. After such a failure, relabelling is not enough:
the ReplicaSet backoff outlives the 300 s timeout, so rollout restart then re-run.
Only Cilium gives an IP to LoadBalancer Services in this lab (L2/ARP announcement).
Calico can only do it over BGP (no peer router on a host-only network) ⇒ MetalLB required,
and loadBalancerClass: io.cilium/l2-announcer in Envoy-Proxy.yml has to go — which is
what platform-up.sh does when the CNI is not Cilium. Changing CNI = vagrant destroy, not
a live switch.
Flannel/VXLAN: without --iface-can-reach=192.168.56.1 — which lives in
talos/cni-flannel.yaml, not in patch-cp.yaml — flannel picks the NAT interface
(10.0.2.15, identical on every VM) ⇒ broken cross-node traffic and DNS. Same for Cilium:
pin the enp0s8 host-only interface.
Vault + integrated Raft: vault-1/vault-2 start NOT initialized. They only join through
retry_join once vault-0 is unsealed, so unsealing them immediately after helm install
fails with 400 — Vault is not initialized. Wait for initialized=true per pod before
unsealing (_k8s/vault-cluster/vault-up.sh does this). Symptom of the race: vault-0 unsealed,
the other two sealed, script dead at exit 2.
jq: // treats false exactly like null..sealed // true therefore returns true
for an unsealed Vault, which made an idempotent re-run try to unseal an open Vault and
abort on 400 — already unsealed. On any boolean field, use .field | tostring and compare
to "true"/"false" instead.
./script.sh; echo "EXIT=$?" reports the exit code of echo, not of the script, so a
background wrapper built that way reports success no matter what failed. Check the EXIT=
line inside the log, or use ${PIPESTATUS[0]} — a shell that swallows failures is worse than
no check at all.
chaoskube is dry-run by default, and _k8s/chaos-kube/ deletes a pod every hour. Without
--no-dry-run the chart only logs would kill … — check dryRun=false in the pod logs, never
the manifest. Going back to dry-run requires REMOVING the no-dry-run key: the chart renders
--<key> for any falsy value, so --set …no-dry-run=null keeps the flag (hence the
mktemp+sed in chaoskube-up.sh). Exclusion list: kube-system, longhorn-system,
vault, cnpg-demo — vault is in there because a killed Vault pod comes back SEALED (no
auto-unseal), and cnpg-demo is the demo Postgres cluster namespace, not the operator's
(cnpg-system, still a target). Excluding a namespace that does not exist yet is harmless.
Hostname: per-node, therefore outside the shared patches. Set at apply-config time
through a HostnameConfig document (auto: "off" + hostname). Vagrant VM name == Talos
hostname.
Dashboard KUBERNETES: n/a: normal in maintenance mode (the KubeletSpec resource only
exists after apply-config). Nothing to fix.
_k8s/longhorn/patch-longhorn.yaml is NOT applied by cluster-up.sh (which only passes
patch-all, patch-cp and cni-*): the rshared mount of /var/lib/longhorn is applied by
_k8s/longhorn/longhorn-up.sh, to the workers, right before the chart. A freshly bootstrapped
cluster therefore has noextraMounts — see _k8s/longhorn/README.md.
The default gateway through NAT 10.0.2.2 is intentional (Internet access). What must be
host-only is the node's identity (kubelet nodeIP / etcd / VIP), not the default route.
Bilingual docs: docs/build.py pairs pages per directory through MIROIRS
(README.md ↔ LISEZ-MOI.md, UPGRADE.md ↔ MISE-A-JOUR.md). A page with no mirror does
not fail the build: it shows up in English inside the French menu, with an EN badge.
That badge is the symptom of a forgotten mirror — except for the pages listed in
SANS_MIROIR (this file), which are English-only on purpose and carry no badge.
FR anchors ≠ EN anchors: slugs derive from headings, so translating a heading breaks
every link that targeted it. *.md links are rewritten into internal routes at build time;
make docs lists whatever no longer resolves. Two anchors are contractual, because many
addons point at them: _k8s/README.md#-lab_domain--the-ui-domain and
#-remote-access-tailscale--cloudflare.
The `` banner at the top of every page is there for GitHub
readers; docs/build.py strips it (it has its own switcher). Do not remove it from the
files, and do not put anything else between the markers.
lab.env is gitignored and holds real secrets (Cloudflare token, Vault token, unseal
keys). Never commit it, never copy its values into a README, a commit, a report or terminal
output.
_out/*.yaml holds the cluster CA and keys; kubeconfig holds the admin credentials.
_k8s/databasement/ is gitignored: its values.yaml carries an application key in the
clear.
Before committing: git status — no secret file may show up.
Bilingual docs, English first: README.md and talos/UPGRADE.md are in English;
their French mirror lives in the same directory — LISEZ-MOI.md, talos/MISE-A-JOUR.md.
Both versions change in the same commit: an English page whose mirror did not follow is
a documentation bug. This file is the exception: it is English-only (it addresses
coding agents, and there is no French mirror to keep in sync).
Commit messages in English, conventional (fix(...), feat(...), docs: ...). Branch
from main, then PR (squash).
Code comments in French (scripts, YAML, docs/build.py): that is the repo's working
language, leave it alone. Exceptions, in English: Vagrantfile and lab.env.example
— they are the first two files a newcomer opens, so they follow the English-first
documentation rule.
⚠️ Adding a component = propagating it EVERYWHERE#
An addon, a variable or an option is only "done" once it is documented at every level. A
single isolated mention is a documentation bug: the reader will never find the component. Run
this checklist on every addition:
Where
What to update
_k8s/<addon>/README.md
the dedicated README (skeleton: 🎯 purpose · 📋 prerequisites · ⚡ install · 🔧 how it works · ✅ verify · 🌐 access · ⚠️ pitfalls · 📚 references)
_k8s/README.md
the index: the table of the right family (storage / databases / secrets / observability / security / networking / demos) and the dependency chain if it changes
README.md (root)
only if it touches the install path, lab.env or the CNI choice
lab.env.example
every new variable, commented, with a neutral default (public repo)
CLAUDE.md
every newly earned pitfall, and every new validation command
talos/UPGRADE.md
if the component requires a system extension or constrains a version
README of the neighbouring addons
the cross-references: the one we depend on, the ones depending on us
docs/build.py
the page emoji in EMOJIS and its placement in GROUPES
the FR mirror of every page touched
LISEZ-MOI.md (and talos/MISE-A-JOUR.md): same structure, same content, same commit as the English version. CLAUDE.md has no mirror
Then make docs to regenerate the page, and make validate before committing.
"Test" topology: edit lab.env (gitignored, therefore never committed). The repo default
stays in lab.env.example (3 CP / 3 workers) — do not change it "just to test".
READMEs follow a shared structure (one emoji per ## heading, ⚠️/💡/ℹ️ callouts) and
are published as HTML by docs/build.py. Stick to standard markdown (CommonMark + GitHub
tables) so the generator renders them correctly.
Source: CLAUDE.md7 sections
CLAUDE.md
🤖CLAUDE.md
Talos Linux on VirtualBox lab, driven by Vagrant. Talos has neither SSH nor a shell:
everything is driven with talosctl from the host. User docs: README.md ·
application layer: _k8s/README.md.
vagrant up creates/starts the VMs (Talos boots off the ISO in maintenance mode).
./talos/cluster-up.sh generates the config, applies it, bootstraps etcd, fetches the
kubeconfig and waits for health. This is the real path (the <details> in §4 of the README
is the manual "to understand what happens" version).
./_k8s/platform-up.sh lays down the base platform, then the addons, opt-in
(_k8s/*/*-up.sh).
The reference lab runs CNI=cilium — the repo default, in lab.env.example, in
talos/cluster-up.sh and in platform-up.sh. Talos installs no CNI at bootstrap and
platform-up.sh installs Cilium right after; that is what the _k8s/ layer assumes
everywhere (LoadBalancer Services depend on Cilium's L2 announcement). CNI=none produces
the exact same machine config but installs nothing at all — it means "I lay down my own CNI",
and platform-up.sh then stops on no node Ready.
"Install X" is still a repo change first. The deliverable is the reproducible path —
manifests, *-up.sh, README — never a hand-rolled kubectl apply that leaves no trace in
git. Deploying to the lab afterwards is fine and expected: run the *-up.sh you just wrote,
which is also how you find out whether it actually works. What is NOT fine is a cluster
carrying state no script can rebuild.
Ask before anything destructive.vagrant destroy, talosctl reset/upgrade,
regenerating _out/, deleting a PVC or a namespace holding data: these are one-way on a lab
that takes ~15 min to rebuild. Reading is always free (kubectl get, talosctl read, helm show values, helm template) — use it to back up your claims rather than guessing.
One feature = one merged PR. Branch from main, conventional commit, PR, squash merge
(1 commit on main). No big catch-all commit mixing several topics: split by feature, even
if that means several PRs back to back.
✅ Validating a change WITHOUT touching a cluster (do this every time)#
makevalidate# bash -n on every script + YAML parse + vagrant validate + config gen
makedocs# regenerates docs/index.html from every README (needs uv)
make validate-yaml alone parses every git-tracked *.yaml/*.yml (PyYAML pulled in by uv,
so nothing to install). The ci workflow re-runs validate-shell, validate-yaml and
validate-vagrant on every PR through the same make targets — never duplicate a check's
definition in the workflow. A runner has no VirtualBox, hence
make validate-vagrant VAGRANT_VALIDATE_FLAGS=--ignore-provider there.
make validate-talos generates the config in an mktemp -d, then feeds it to talosctl validate --mode metal: neither _out/ nor the cluster is touched. To test a patch against an
existing config without applying it: talosctl machineconfig patch <file> --patch <inline|@file> -o /tmp/x.yaml, then validate.
make docs regenerates the bilingual page and lists, at the end of the build, every *.md
link and cross-file anchor that does not resolve. make validate-docs (included in make validate) builds into a throwaway directory and fails on the first unresolved link —
that is the guard to run after renaming a heading or adding a page.
Do NOT re-run cluster-up.sh against an already-installed cluster: wait_maintenance
polls get disks --insecure, which a node in secure mode never answers. Both waits are
bounded since #53 (WAIT_MAINTENANCE, 300 s; WAIT_SECURE, 600 s — both overridable) and
fail with a message naming the two likely causes, so this no longer hangs forever — it just
wastes the timeout. To grow a running cluster: README §6.1.
Do NOT regenerate _out/ (nor FORCE=1) on a running cluster: new secrets/CA ⇒ broken
cluster. Only regenerate after vagrant destroy.
Addressing: topology and addressing live in lab.env (single source read by both the
Vagrantfile AND talos/cluster-up.sh). Versioned template lab.env.example; lab.env is
gitignored. CP = .10/.20/.30, workers = .101+. A real environment variable still wins
(WORKERS=6 vagrant up).
NETWORK is only half configurable: 192.168.56.x is hardcoded in
talos/patch-all.yaml (validSubnets), talos/patch-cp.yaml (vip.ip,
advertisedSubnets) and talos/cni-flannel.yaml (--iface-can-reach). Changing NETWORK
without editing those three files gives you a silently broken cluster.
Three places carry the Talos version: Vagrantfile (fallback default),
talos/cluster-up.sh (fallback default) and lab.env. Both defaults are now aligned on
v1.13.7 — keep them that way on every bump, and remember that INSTALLER_IMAGE (factory
image, tag included) overrides TALOS_VERSION for what actually lands on disk.
Never lower CP_MEM below 3072: 2 GB control planes starve etcd as soon as _k8s/
addons stack up. The template now ships 4096 (and so does the Vagrantfile fallback),
which observability/ requires. Cost of the default topology: 18 GB of host RAM.
Renaming VMs: destroy (vagrant destroy) BEFORE changing s[:name] in the
Vagrantfile, otherwise the old VMs become orphans in VirtualBox.
vagrant up fails after a destroy (VERR_ALREADY_EXISTS on the temp_clone_…
rename): VirtualBox 7.x leaves orphaned ~/VirtualBox VMs/talos-*/ directories plus dead
entries in the media registry. Cleanup: ./talos/virtualbox-cleanup.sh (idempotent,
DRY_RUN=1 to preview). NEVER on a running cluster — and note that it also deletes
temp_clone_* VMs, including those of another Vagrant project mid-up.
Disk sentinel: the Vagrantfile considers a VM provisioned if
.vagrant/talos-disks/<vm>.vdi exists. A destroy that fails and leaves the .vdi behind
makes the next up create a VM with no disk attached, with an obscure install error.
CNI: CNI=cilium|calico|flannel|none (default cilium) expresses an intent, read in
two places — cluster-up.sh applies talos/cni-<CNI>.yaml, then platform-up.sh installs
the CNI unless Talos already did. Only flannel is laid down by Talos at bootstrap time
(cluster.network.cni); cilium and calico go through cni.name: none then Helm. Any
manual gen config MUST include --config-patch-control-plane @talos/cni-<CNI>.yamland--install-image "$INSTALLER_IMAGE" — without it the classic installer is laid down,
without the iscsi extensions, and Longhorn fails later on iscsiadm: not found.
TLS: SELF_SIGNED=true is the default, and it skips cert-manager entirely.platform-up.sh step [4/4] branches on it: true runs
_k8s/self-signed/selfsigned-up.sh (local CA + openssl wildcard into
_out/self-signed/, then the TLS Secret) and strips the
cert-manager.io/cluster-issuer annotation from main-gateway; false installs
cert-manager as before. Both modes fill the SAME Secret
(wildcard-<LAB_DOMAIN with dashes>-tls), so no addon ever branches on the TLS mode —
keep it that way. LAB_DNS_ZONE, LAB_ACME_EMAIL, LAB_ACME_ISSUER and
CLOUDFLARE_API_TOKEN are dead variables when SELF_SIGNED=true. Switching modes on a
live cluster leaves the other mode's object behind (a Certificate, or a hand-made
Secret) — see _k8s/self-signed/README.md §⚠️.
ACME: staging is the default, and prod has a weekly quota (SELF_SIGNED=false only). LAB_ACME_ISSUER
(staging|prod, default staging) drives the cert-manager.io/cluster-issuer annotation —
the versioned Envoy-Proxy.yml carries letsencrypt-staging, and platform-up.sh rewrites
it. Do NOT switch the repo default back to prod: the wildcard lives only in etcd, so
every vagrant destroy burns one of the 5 certificates/week per identifier set Let's
Encrypt production allows. Already hit on 2026-07-26: 5/5 consumed, 429 rateLimited, no TLS
for 18 h — while the destroyed cert was valid for another 3 months. Before a destroy on a
prod lab: kubectl -n envoy-gateway-system get secret <wildcard>-tls -o yaml > _out/wildcard-tls.backup.yaml (private key inside — _out/ is gitignored).
Calico/tigera-operator: two bootstrap traps, both fixed in _k8s/calico/ — do not undo
them. (1) The chart renders four CRs (Installation, APIServer, Goldmane, Whisker)
but ships no crds/ directory — the operator creates the CRDs at runtime
(-manage-crds=true), so any CR left enabled kills helm install on a fresh cluster with
no matches for kind … ensure CRDs are installed first. All four stay enabled=false; the
ones we want live in installation.yaml / apiserver.yaml, applied after the CRD wait.
(2) The operator needs hostNetwork + a hostPath, which Talos's default baseline
PodSecurity rejects, and helm --create-namespace sets no PSS label ⇒ _k8s/calico/namespace.yaml
must be applied before the chart. Failure mode is nasty: get pods shows zero pod
(not a failing one), the Deployment just never rolls out — the reason is only in
kubectl -n tigera-operator describe rs. After such a failure, relabelling is not enough:
the ReplicaSet backoff outlives the 300 s timeout, so rollout restart then re-run.
Only Cilium gives an IP to LoadBalancer Services in this lab (L2/ARP announcement).
Calico can only do it over BGP (no peer router on a host-only network) ⇒ MetalLB required,
and loadBalancerClass: io.cilium/l2-announcer in Envoy-Proxy.yml has to go — which is
what platform-up.sh does when the CNI is not Cilium. Changing CNI = vagrant destroy, not
a live switch.
Flannel/VXLAN: without --iface-can-reach=192.168.56.1 — which lives in
talos/cni-flannel.yaml, not in patch-cp.yaml — flannel picks the NAT interface
(10.0.2.15, identical on every VM) ⇒ broken cross-node traffic and DNS. Same for Cilium:
pin the enp0s8 host-only interface.
Vault + integrated Raft: vault-1/vault-2 start NOT initialized. They only join through
retry_join once vault-0 is unsealed, so unsealing them immediately after helm install
fails with 400 — Vault is not initialized. Wait for initialized=true per pod before
unsealing (_k8s/vault-cluster/vault-up.sh does this). Symptom of the race: vault-0 unsealed,
the other two sealed, script dead at exit 2.
jq: // treats false exactly like null..sealed // true therefore returns true
for an unsealed Vault, which made an idempotent re-run try to unseal an open Vault and
abort on 400 — already unsealed. On any boolean field, use .field | tostring and compare
to "true"/"false" instead.
./script.sh; echo "EXIT=$?" reports the exit code of echo, not of the script, so a
background wrapper built that way reports success no matter what failed. Check the EXIT=
line inside the log, or use ${PIPESTATUS[0]} — a shell that swallows failures is worse than
no check at all.
chaoskube is dry-run by default, and _k8s/chaos-kube/ deletes a pod every hour. Without
--no-dry-run the chart only logs would kill … — check dryRun=false in the pod logs, never
the manifest. Going back to dry-run requires REMOVING the no-dry-run key: the chart renders
--<key> for any falsy value, so --set …no-dry-run=null keeps the flag (hence the
mktemp+sed in chaoskube-up.sh). Exclusion list: kube-system, longhorn-system,
vault, cnpg-demo — vault is in there because a killed Vault pod comes back SEALED (no
auto-unseal), and cnpg-demo is the demo Postgres cluster namespace, not the operator's
(cnpg-system, still a target). Excluding a namespace that does not exist yet is harmless.
Hostname: per-node, therefore outside the shared patches. Set at apply-config time
through a HostnameConfig document (auto: "off" + hostname). Vagrant VM name == Talos
hostname.
Dashboard KUBERNETES: n/a: normal in maintenance mode (the KubeletSpec resource only
exists after apply-config). Nothing to fix.
_k8s/longhorn/patch-longhorn.yaml is NOT applied by cluster-up.sh (which only passes
patch-all, patch-cp and cni-*): the rshared mount of /var/lib/longhorn is applied by
_k8s/longhorn/longhorn-up.sh, to the workers, right before the chart. A freshly bootstrapped
cluster therefore has noextraMounts — see _k8s/longhorn/README.md.
The default gateway through NAT 10.0.2.2 is intentional (Internet access). What must be
host-only is the node's identity (kubelet nodeIP / etcd / VIP), not the default route.
Bilingual docs: docs/build.py pairs pages per directory through MIROIRS
(README.md ↔ LISEZ-MOI.md, UPGRADE.md ↔ MISE-A-JOUR.md). A page with no mirror does
not fail the build: it shows up in English inside the French menu, with an EN badge.
That badge is the symptom of a forgotten mirror — except for the pages listed in
SANS_MIROIR (this file), which are English-only on purpose and carry no badge.
FR anchors ≠ EN anchors: slugs derive from headings, so translating a heading breaks
every link that targeted it. *.md links are rewritten into internal routes at build time;
make docs lists whatever no longer resolves. Two anchors are contractual, because many
addons point at them: _k8s/README.md#-lab_domain--the-ui-domain and
#-remote-access-tailscale--cloudflare.
The `` banner at the top of every page is there for GitHub
readers; docs/build.py strips it (it has its own switcher). Do not remove it from the
files, and do not put anything else between the markers.
lab.env is gitignored and holds real secrets (Cloudflare token, Vault token, unseal
keys). Never commit it, never copy its values into a README, a commit, a report or terminal
output.
_out/*.yaml holds the cluster CA and keys; kubeconfig holds the admin credentials.
_k8s/databasement/ is gitignored: its values.yaml carries an application key in the
clear.
Before committing: git status — no secret file may show up.
Bilingual docs, English first: README.md and talos/UPGRADE.md are in English;
their French mirror lives in the same directory — LISEZ-MOI.md, talos/MISE-A-JOUR.md.
Both versions change in the same commit: an English page whose mirror did not follow is
a documentation bug. This file is the exception: it is English-only (it addresses
coding agents, and there is no French mirror to keep in sync).
Commit messages in English, conventional (fix(...), feat(...), docs: ...). Branch
from main, then PR (squash).
Code comments in French (scripts, YAML, docs/build.py): that is the repo's working
language, leave it alone. Exceptions, in English: Vagrantfile and lab.env.example
— they are the first two files a newcomer opens, so they follow the English-first
documentation rule.
⚠️ Adding a component = propagating it EVERYWHERE#
An addon, a variable or an option is only "done" once it is documented at every level. A
single isolated mention is a documentation bug: the reader will never find the component. Run
this checklist on every addition:
Where
What to update
_k8s/<addon>/README.md
the dedicated README (skeleton: 🎯 purpose · 📋 prerequisites · ⚡ install · 🔧 how it works · ✅ verify · 🌐 access · ⚠️ pitfalls · 📚 references)
_k8s/README.md
the index: the table of the right family (storage / databases / secrets / observability / security / networking / demos) and the dependency chain if it changes
README.md (root)
only if it touches the install path, lab.env or the CNI choice
lab.env.example
every new variable, commented, with a neutral default (public repo)
CLAUDE.md
every newly earned pitfall, and every new validation command
talos/UPGRADE.md
if the component requires a system extension or constrains a version
README of the neighbouring addons
the cross-references: the one we depend on, the ones depending on us
docs/build.py
the page emoji in EMOJIS and its placement in GROUPES
the FR mirror of every page touched
LISEZ-MOI.md (and talos/MISE-A-JOUR.md): same structure, same content, same commit as the English version. CLAUDE.md has no mirror
Then make docs to regenerate the page, and make validate before committing.
"Test" topology: edit lab.env (gitignored, therefore never committed). The repo default
stays in lab.env.example (3 CP / 3 workers) — do not change it "just to test".
READMEs follow a shared structure (one emoji per ## heading, ⚠️/💡/ℹ️ callouts) and
are published as HTML by docs/build.py. Stick to standard markdown (CommonMark + GitHub
tables) so the generator renders them correctly.