Vagrant-Talos
README.md

🏠 🐧Vagrant-Talos

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…).

Vagrant-Talos — the Vagrant logo next to the Talos Linux logo

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:

vagrant up                      # 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)
📖 Browsable docs ops-nc.github.io/Vagrant-Talos — EN/FR switch, offline copy with make docs
📦 Application layer _k8s/README.md
⬆️ Talos / K8s upgrades talos/UPGRADE.md
🚑 Something broken? TROUBLESHOOTING.md

🧰 1. Prerequisites (on the host)#

Tool Purpose Install
VirtualBox 7 hypervisor https://www.virtualbox.org/
Vagrant VM creation https://developer.hashicorp.com/vagrant
talosctl driving the Talos cluster curl -sL https://talos.dev/install | sh
kubectl using the cluster https://kubernetes.io/docs/tasks/tools/
helm _k8s/ addons https://helm.sh/docs/intro/install/
uv (optional) make docs https://docs.astral.sh/uv/

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

curl -Lo /tmp/talosctl https://github.com/siderolabs/talos/releases/download/v1.13.7/talosctl-linux-amd64
sudo install -m 0755 /tmp/talosctl /usr/local/bin/talosctl

ℹ️ 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-only 192.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.


⚙️ 3. Pick the topology — lab.env#

The topology lives in lab.env, the single source read by the Vagrantfile and by talos/cluster-up.sh. Start from the versioned template (lab.env is gitignored):

cp lab.env.example lab.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=1
WORKERS=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).


🚀 4. Start the cluster#

vagrant up                      # 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.

export TALOSCONFIG="$PWD/_out/talosconfig"
export KUBECONFIG="$PWD/kubeconfig"
kubectl get nodes -o wide

For another topology, edit lab.env or override on the spot:

CONTROL_PLANES=1 WORKERS=2 ./talos/cluster-up.sh     # single
CNI=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.

4.1 Start the VMs#

vagrant up

The VMs boot from the Talos ISO in maintenance mode and take their reserved IP. Check that a node answers:

talosctl -n 192.168.56.10 get disks --insecure   # must list /dev/sda

4.2 Generate the Talos configuration#

set -a ; . ./lab.env ; set +a        # loads TALOS_VERSION, INSTALLER_IMAGE, CNI…

talosctl gen config talos-lab https://192.168.56.5:6443 \
  --install-disk /dev/sda \
  --install-image "$INSTALLER_IMAGE" \
  --additional-sans 192.168.56.5,192.168.56.10,192.168.56.20,192.168.56.30 \
  --config-patch               @talos/patch-all.yaml \
  --config-patch-control-plane @talos/patch-cp.yaml \
  --config-patch-control-plane "@talos/cni-${CNI:-cilium}.yaml" \
  --output-dir _out

export TALOSCONFIG="$PWD/_out/talosconfig"

Produces _out/controlplane.yaml, _out/worker.yaml and _out/talosconfig. The kube-apiserver endpoint is the VIP 192.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
talosctl apply-config --insecure -n 192.168.56.10 --file _out/controlplane.yaml

# Workers (.101, .102, … — independent of the number of CPs)
talosctl apply-config --insecure -n 192.168.56.101 --file _out/worker.yaml
talosctl apply-config --insecure -n 192.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:).

4.4 Point talosctl at the cluster#

talosctl config endpoint 192.168.56.10        # HA: add .20 .30
talosctl config node     192.168.56.10

4.5 Bootstrap etcd (ONCE ONLY, on the 1st CP)#

talosctl bootstrap -n 192.168.56.10

⚠️ 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.

4.6 Kubeconfig and health#

talosctl kubeconfig -n 192.168.56.10 ./kubeconfig
export KUBECONFIG="$PWD/kubeconfig"

talosctl health --wait-timeout 10m -n 192.168.56.10 -e 192.168.56.10
talosctl -n 192.168.56.10 get members      # members seen by discovery
kubectl get nodes -o wide

📦 5. What comes next: the application layer#

A bare cluster does nothing useful. Everything else lives in _k8s/: Cilium, Envoy Gateway, cert-manager, Longhorn, Vault, PostgreSQL, Prometheus/Loki, Kyverno, Trivy, MinIO…

./talos/cluster-up.sh              # 1. cluster (CNI=cilium by default: Talos installs nothing)
./_k8s/platform-up.sh              # 2. Cilium → Envoy Gateway → metrics-server → wildcard TLS
./_k8s/argocd/argocd-up.sh         # 3. opt-in addons

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.

5.1 DNS + TLS: the two manual prerequisites#

ℹ️ 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 -n envoy-gateway-system get svc -o wide | grep LoadBalancer

# check resolution
dig +short argo.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:

curl -sI --resolve argo.talos.lab.example.io:443:192.168.56.200 \
  https://argo.talos.lab.example.io/

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 read
LAB_DOMAIN=talos.lab.example.io        # your domain
LAB_DNS_ZONE=example.io                # the Cloudflare zone (derived if empty)
LAB_ACME_EMAIL=you@example.io          # Let's Encrypt expiry notices
LAB_ACME_ISSUER=staging                # staging (default) | prod — see the warning below
CLOUDFLARE_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 -n envoy-gateway-system get secret wildcard-<your-domain-in-dashes>-tls \
  -o yaml > _out/wildcard-tls.backup.yaml     # contains the private key: _out/ is gitignored

♻️ 6. Lifecycle#

vagrant status                 # VM status
vagrant halt                   # power off
vagrant up                     # power back on
vagrant destroy -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):

  1. Raise WORKERS in lab.env (here WORKERS=5).
  2. Start only the new VMs:
    vagrant up talos-w4 talos-w5
    
  3. Apply the existing worker config while pinning the hostname (Nth worker = talos-w<N>):
    export TALOSCONFIG="$PWD/_out/talosconfig"
    WK_IP_START=101 ; WK_IP_STEP=1              # same values as lab.env
    for n in 4 5; do
      ip="192.168.56.$(( WK_IP_START + (n - 1) * WK_IP_STEP ))"
      until talosctl -n "$ip" get disks --insecure >/dev/null 2>&1; do sleep 5; done
      talosctl apply-config --insecure -n "$ip" --file _out/worker.yaml \
        --config-patch "$(printf 'apiVersion: v1alpha1\nkind: HostnameConfig\nauto: "off"\nhostname: talos-w%s\n' "$n")"
    done
    
  4. The workers join automatically (their config already points at the VIP). Check: kubectl get nodes -o widetalos-w4/talos-w5 turn Ready.

💡 Removing a worker:

kubectl drain talos-w5 --ignore-daemonsets --delete-emptydir-data
vagrant destroy -f talos-w5
kubectl delete node talos-w5

then lower WORKERS in lab.env.

ℹ️ 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.


🚑 7. Troubleshooting#

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.


🔍 8. How it works (under the hood)#

  • 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 hostnamescluster-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 / HAtalos/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 discoverytalos/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.

References: Talos Linux · siderolabs/talos · rgl/talos-vagrant · bjwschaap/vagrant-empty-box


🌐 9. CNI: Cilium, Calico or Flannel#

Two ways to install a CNI, and a single variable#

CNI (in lab.env) expresses an intent, read in two places:

  1. 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.
  2. _k8s/platform-up.sh installs the CNI in every other case, through Helm.
CNI= Talos patch Who installs LoadBalancer IP
cilium (default) cni-cilium.yamlnone platform-up.sh_k8s/cilium/ ✅ pool + L2 announcement (ARP)
calico cni-calico.yamlnone platform-up.sh_k8s/calico/ ❌ BGP only
flannel cni-flannel.yaml Talos, at bootstrap time
none cni-none.yaml you
CNI=calico ./talos/cluster-up.sh && ./_k8s/platform-up.sh

Which one to choose?#

Cilium Calico Flannel
Getting started one script after the bootstrap one script after the bootstrap immediate, Talos does it all
Pod network + NetworkPolicy ⚠️ no NetworkPolicy
LoadBalancer Services ✅ L2 announcement ❌ MetalLB required
_k8s/ layer (Envoy, HTTPS UIs) ⚠️ after MetalLB ❌ unusable
kube-proxy replacement ✅ possible
Network observability Hubble

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.


🛠️ 10. Validating a change#

Everything can be validated without touching a cluster:

make validate       # script syntax + Vagrantfile + Talos config + doc links
make docs           # regenerates docs/index.html from every README (EN + FR)
make help           # 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.

📄 11. License#

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…).

Vagrant-Talos — le logo Vagrant à côté du logo Talos Linux

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 :

vagrant up                      # 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)
📖 Documentation navigable ops-nc.github.io/Vagrant-Talos — bascule EN/FR, copie hors ligne avec make docs
📦 Couche applicative _k8s/LISEZ-MOI.md
⬆️ Mises à jour Talos / K8s talos/MISE-A-JOUR.md
🚑 Quelque chose casse ? DEPANNAGE.md

🧰 1. Prérequis (sur l'hôte)#

Outil Rôle Installation
VirtualBox 7 hyperviseur https://www.virtualbox.org/
Vagrant création des VMs https://developer.hashicorp.com/vagrant
talosctl pilotage du cluster Talos curl -sL https://talos.dev/install | sh
kubectl utilisation du cluster https://kubernetes.io/docs/tasks/tools/
helm addons _k8s/ https://helm.sh/docs/intro/install/
uv (optionnel) make docs https://docs.astral.sh/uv/

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

curl -Lo /tmp/talosctl https://github.com/siderolabs/talos/releases/download/v1.13.7/talosctl-linux-amd64
sudo install -m 0755 /tmp/talosctl /usr/local/bin/talosctl

ℹ️ 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-only 192.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é.


⚙️ 3. Choisir la topologie — lab.env#

La topologie vit dans lab.env, source unique lue par le Vagrantfile et talos/cluster-up.sh. Partir du modèle versionné (lab.env est gitignoré) :

cp lab.env.example lab.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=1
WORKERS=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).


🚀 4. Démarrer le cluster#

vagrant up                      # 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.

export TALOSCONFIG="$PWD/_out/talosconfig"
export KUBECONFIG="$PWD/kubeconfig"
kubectl get nodes -o wide

Pour une autre topologie, éditer lab.env ou surcharger ponctuellement :

CONTROL_PLANES=1 WORKERS=2 ./talos/cluster-up.sh     # single
CNI=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.

4.1 Lancer les VMs#

vagrant up

Les VMs bootent sur l'ISO Talos en mode maintenance et prennent leur IP réservée. Vérifier qu'un node répond :

talosctl -n 192.168.56.10 get disks --insecure   # doit lister /dev/sda

4.2 Générer la configuration Talos#

set -a ; . ./lab.env ; set +a        # charge TALOS_VERSION, INSTALLER_IMAGE, CNI…

talosctl gen config talos-lab https://192.168.56.5:6443 \
  --install-disk /dev/sda \
  --install-image "$INSTALLER_IMAGE" \
  --additional-sans 192.168.56.5,192.168.56.10,192.168.56.20,192.168.56.30 \
  --config-patch               @talos/patch-all.yaml \
  --config-patch-control-plane @talos/patch-cp.yaml \
  --config-patch-control-plane "@talos/cni-${CNI:-cilium}.yaml" \
  --output-dir _out

export TALOSCONFIG="$PWD/_out/talosconfig"

Produit _out/controlplane.yaml, _out/worker.yaml et _out/talosconfig. L'endpoint kube-apiserver est la VIP 192.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
talosctl apply-config --insecure -n 192.168.56.10 --file _out/controlplane.yaml

# Workers (.101, .102, … — indépendant du nombre de CP)
talosctl apply-config --insecure -n 192.168.56.101 --file _out/worker.yaml
talosctl apply-config --insecure -n 192.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:).

4.4 Pointer talosctl sur le cluster#

talosctl config endpoint 192.168.56.10        # HA : ajouter .20 .30
talosctl config node     192.168.56.10

4.5 Bootstrap etcd (UNE SEULE FOIS, sur le 1er CP)#

talosctl bootstrap -n 192.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.

4.6 Kubeconfig et santé#

talosctl kubeconfig -n 192.168.56.10 ./kubeconfig
export KUBECONFIG="$PWD/kubeconfig"

talosctl health --wait-timeout 10m -n 192.168.56.10 -e 192.168.56.10
talosctl -n 192.168.56.10 get members      # membres vus par la discovery
kubectl get nodes -o wide

📦 5. Et après : la couche applicative#

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.

5.1 DNS + TLS : les deux prérequis manuels#

ℹ️ 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 -n envoy-gateway-system get svc -o wide | grep LoadBalancer

# vérifier la résolution
dig +short argo.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 :

curl -sI --resolve argo.talos.lab.example.io:443:192.168.56.200 \
  https://argo.talos.lab.example.io/

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 lu
LAB_DOMAIN=talos.lab.example.io        # ton domaine
LAB_DNS_ZONE=example.io                # la zone Cloudflare (déduite si vide)
LAB_ACME_EMAIL=toi@example.io          # avis d'expiration Let's Encrypt
LAB_ACME_ISSUER=staging                # staging (défaut) | prod — voir l'avertissement plus bas
CLOUDFLARE_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 -n envoy-gateway-system get secret wildcard-<ton-domaine-en-tirets>-tls \
  -o yaml > _out/wildcard-tls.backup.yaml     # contient la clé privée : _out/ est gitignoré

♻️ 6. Cycle de vie#

vagrant status                 # état des VMs
vagrant halt                   # éteindre
vagrant up                     # rallumer
vagrant destroy -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) :

  1. Augmenter WORKERS dans lab.env (ici WORKERS=5).
  2. Démarrer uniquement les nouvelles VMs :
    vagrant up talos-w4 talos-w5
    
  3. Appliquer la config worker existante en fixant le hostname (Nᵉ worker = talos-w<N>) :
    export TALOSCONFIG="$PWD/_out/talosconfig"
    WK_IP_START=101 ; WK_IP_STEP=1              # mêmes valeurs que lab.env
    for n in 4 5; do
      ip="192.168.56.$(( WK_IP_START + (n - 1) * WK_IP_STEP ))"
      until talosctl -n "$ip" get disks --insecure >/dev/null 2>&1; do sleep 5; done
      talosctl apply-config --insecure -n "$ip" --file _out/worker.yaml \
        --config-patch "$(printf 'apiVersion: v1alpha1\nkind: HostnameConfig\nauto: "off"\nhostname: talos-w%s\n' "$n")"
    done
    
  4. Les workers rejoignent automatiquement (leur config pointe déjà sur la VIP). Vérifier : kubectl get nodes -o widetalos-w4/talos-w5 passent Ready.

💡 Retirer un worker :

kubectl drain talos-w5 --ignore-daemonsets --delete-emptydir-data
vagrant destroy -f talos-w5
kubectl delete node talos-w5

puis réduire WORKERS dans lab.env.

ℹ️ 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.


🚑 7. Dépannage#

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.


🔍 8. Comment ça marche (sous le capot)#

  • 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éterministescluster-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 / HAtalos/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 onlinetalos/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.

Références : Talos Linux · siderolabs/talos · rgl/talos-vagrant · bjwschaap/vagrant-empty-box


🌐 9. CNI : Cilium, Calico ou Flannel#

Deux façons d'installer un CNI, et une seule variable#

CNI (dans lab.env) exprime une intention, lue à deux endroits :

  1. 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.
  2. _k8s/platform-up.sh installe le CNI dans tous les autres cas, par Helm.
CNI= Patch Talos Qui installe IP LoadBalancer
cilium (défaut) cni-cilium.yamlnone platform-up.sh_k8s/cilium/ ✅ pool + annonce L2 (ARP)
calico cni-calico.yamlnone platform-up.sh_k8s/calico/ ❌ BGP seulement
flannel cni-flannel.yaml Talos, au bootstrap
none cni-none.yaml toi
CNI=calico ./talos/cluster-up.sh && ./_k8s/platform-up.sh

Lequel choisir ?#

Cilium Calico Flannel
Mise en route un script après le bootstrap un script après le bootstrap immédiate, Talos fait tout
Réseau pod + NetworkPolicy ⚠️ pas de NetworkPolicy
Services LoadBalancer ✅ annonce L2 ❌ MetalLB requis
Couche _k8s/ (Envoy, UI HTTPS) ⚠️ après MetalLB ❌ inutilisable
Remplacement de kube-proxy ✅ possible
Observabilité réseau Hubble

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.


🛠️ 10. Valider un changement#

Tout se valide sans toucher à un cluster :

make validate       # syntaxe des scripts + Vagrantfile + config Talos + liens de la doc
make docs           # régénère docs/index.html depuis tous les README (EN + FR)
make help           # 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.

📄 11. Licence#

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.

🎯 1. How Talos upgrades itself#

Talos has no SSH and no package manager: an upgrade replaces the system image on disk, it patches nothing in place.

talosctl -n <ip-node> upgrade --image ghcr.io/siderolabs/installer:<vX.Y.Z>
  • 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.

🔢 2. The version model in this lab#

Three version references coexist, and they do not move together:

Reference Purpose Driven by
ISO metal-amd64.iso boot in maintenance mode, before the install TALOS_VERSION (lab.env)
Installer image version actually installed on disk INSTALLER_IMAGE, otherwise ghcr.io/siderolabs/installer:${TALOS_VERSION}
talosctl binary generated config schema, command compatibility your local install

⚠️ 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.

⚡ 3. Procedure (running cluster)#

Pre-flight — never start from an already degraded cluster:

export TALOSCONFIG=_out/talosconfig KUBECONFIG=./kubeconfig
talosctl -n 192.168.56.10 -e 192.168.56.10 health        # healthy cluster
talosctl -n 192.168.56.10 -e 192.168.56.10 etcd status   # 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 version
IMG=ghcr.io/siderolabs/installer:${NEW}        # ⚠️ see the "extensions" callout below

# a) Workers, one by one
for ip in 101 102 103 104 105; do
  talosctl -n 192.168.56.$ip -e 192.168.56.10 upgrade --image "$IMG" --preserve --wait
  kubectl wait --for=condition=Ready node/talos-w$((ip-100)) --timeout=5m
done

# b) Control planes, one by one, etcd checked BETWEEN each one
for ip in 10 20 30; do
  talosctl -n 192.168.56.$ip -e 192.168.56.20 upgrade --image "$IMG" --preserve --wait
  talosctl -n 192.168.56.10 -e 192.168.56.10 etcd status
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.

☸️ 4. Upgrading Kubernetes#

Talos and Kubernetes upgrade independently.

talosctl -n 192.168.56.10 -e 192.168.56.10 upgrade-k8s --to 1.37.x

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.

🧩 5. Adding extensions = the same mechanism#

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.

SCHEMATIC_ID=$(curl -sX POST --data-binary @_k8s/longhorn/schematic.yaml \
  https://factory.talos.dev/schematics -H "Content-Type: application/yaml" | jq -r .id)

talosctl -n 192.168.56.101 -e 192.168.56.10 upgrade \
  --image factory.talos.dev/installer/${SCHEMATIC_ID}:v1.13.7 --preserve --wait

talosctl -n 192.168.56.101 get extensions      # iscsi-tools + util-linux-tools present

⚠️ 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.

✅ 6. After the upgrade#

talosctl -n 192.168.56.10 version
kubectl get nodes -o wide
  • Bump TALOS_VERSION and INSTALLER_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.
  • Bump the local talosctl binary to stay aligned.

📊 7. Real test: v1.13.5 → v1.13.7#

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.

talosctl --endpoints <A_CP_OTHER_THAN_THE_TARGET> --nodes 192.168.56.<x> \
  upgrade --image factory.talos.dev/installer/613e1592…:v1.13.7 --preserve --drain=false

Two pitfalls hit along the way#

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

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

Measured results#

Node Role Duration (reboot + back to healthy)
talos-w1w5 workers ~57–120 s each
talos-cp1 CP ~88 s
talos-cp2 CP ~57 s
talos-cp3 CP ~72 s
Total 8 nodes ~10 min end to end

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

📚 References#

Source: talos/UPGRADE.md10 sections
talos/MISE-A-JOUR.md

⬆️Upgrade Talos (et Kubernetes)

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.

🎯 1. Comment Talos s'upgrade#

Talos n'a ni SSH ni gestionnaire de paquets : un upgrade remplace l'image système sur le disque, il ne patche rien en place.

talosctl -n <ip-node> upgrade --image ghcr.io/siderolabs/installer:<vX.Y.Z>
  • 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.

🔢 2. Le modèle de version dans ce lab#

Trois références de version coexistent, et elles ne bougent pas ensemble :

Référence Rôle Piloté par
ISO metal-amd64.iso boot en mode maintenance, avant l'install TALOS_VERSION (lab.env)
Image d'installeur version réellement installée sur disque INSTALLER_IMAGE, sinon ghcr.io/siderolabs/installer:${TALOS_VERSION}
Binaire talosctl 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.

⚡ 3. Procédure (cluster en route)#

Pré-vol — ne jamais partir d'un cluster déjà dégradé :

export TALOSCONFIG=_out/talosconfig KUBECONFIG=./kubeconfig
talosctl -n 192.168.56.10 -e 192.168.56.10 health        # cluster sain
talosctl -n 192.168.56.10 -e 192.168.56.10 etcd status   # 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 cible
IMG=ghcr.io/siderolabs/installer:${NEW}        # ⚠️ voir l'encart « extensions » ci-dessous

# a) Workers, un par un
for ip in 101 102 103 104 105; do
  talosctl -n 192.168.56.$ip -e 192.168.56.10 upgrade --image "$IMG" --preserve --wait
  kubectl wait --for=condition=Ready node/talos-w$((ip-100)) --timeout=5m
done

# b) Control planes, un par un, etcd vérifié ENTRE chaque
for ip in 10 20 30; do
  talosctl -n 192.168.56.$ip -e 192.168.56.20 upgrade --image "$IMG" --preserve --wait
  talosctl -n 192.168.56.10 -e 192.168.56.10 etcd status
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.

☸️ 4. Upgrade de Kubernetes#

Talos et Kubernetes s'upgradent indépendamment.

talosctl -n 192.168.56.10 -e 192.168.56.10 upgrade-k8s --to 1.37.x

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.

🧩 5. Ajouter des extensions = le même mécanisme#

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.

SCHEMATIC_ID=$(curl -sX POST --data-binary @_k8s/longhorn/schematic.yaml \
  https://factory.talos.dev/schematics -H "Content-Type: application/yaml" | jq -r .id)

talosctl -n 192.168.56.101 -e 192.168.56.10 upgrade \
  --image factory.talos.dev/installer/${SCHEMATIC_ID}:v1.13.7 --preserve --wait

talosctl -n 192.168.56.101 get extensions      # iscsi-tools + util-linux-tools présents

⚠️ 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.

✅ 6. Après l'upgrade#

talosctl -n 192.168.56.10 version
kubectl get nodes -o wide
  • Bumper TALOS_VERSION et INSTALLER_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é.

📊 7. Test réel : v1.13.5 → v1.13.7#

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.

talosctl --endpoints <UN_CP_DIFFERENT_DE_LA_CIBLE> --nodes 192.168.56.<x> \
  upgrade --image factory.talos.dev/installer/613e1592…:v1.13.7 --preserve --drain=false

Deux pièges rencontrés#

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

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

Résultats mesurés#

Node Rôle Durée (reboot + retour sain)
talos-w1w5 workers ~57–120 s chacun
talos-cp1 CP ~88 s
talos-cp2 CP ~57 s
talos-cp3 CP ~72 s
Total 8 nodes ~10 min bout à bout

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

📚 Références#

Source : talos/MISE-A-JOUR.md10 sections
TROUBLESHOOTING.md

🚑Troubleshooting

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.


🖥️ 1. Host and VirtualBox#

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 | grep kvm

# 2. Unload (fails if a KVM/libvirt VM is still running — stop it first)
sudo modprobe -r kvm_intel kvm      # 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:

echo -e "blacklist kvm_intel\nblacklist kvm" | sudo tee /etc/modprobe.d/disable-kvm.conf

To revert: delete that file and reboot (or sudo modprobe kvm_intel).

VirtualBox refuses the 192.168.56.0/24 network#

Allow the range in /etc/vbox/networks.conf:

* 192.168.56.0/21

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 AFTER vagrant 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.

vagrant up fails on storagectl ... --remove SAS#

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.


🗺️ 2. Addressing and DHCP#

A node does not get its .x IP#

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 = falsetrue 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)
for v in talos-cp1 talos-cp2 talos-cp3; do VBoxManage controlvm "$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*
VBoxManage dhcpserver restart --network HostInterfaceNetworking-vboxnet0

# 3. power back on: the nodes redo a DHCP DISCOVER and get their reserved IP
vagrant up

Check: talosctl -n 192.168.56.10 version --insecure must answer NODE: 192.168.56.10.

The VIP 192.168.56.5 is unreachable#

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.


🐧 3. Talos nodes#

talosctl ... --insecure does not answer#

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.

The Talos console shows KUBERNETES: n/a#

Normal before apply-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.

The install disk is not /dev/sda#

Check with talosctl -n <ip> get disks --insecure and adjust INSTALL_DISK.


☸️ 4. Cluster and pods#

Pods can ping the Internet but have no DNS#

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.

kubectl get nodes -o custom-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:

kubectl -n kube-system patch ds kube-flannel --type=json \
  -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--iface-can-reach=192.168.56.1"}]'
kubectl -n kube-system rollout status ds/kube-flannel

ℹ️ 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.

The nodes stay NotReady after the bootstrap#

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 still NotReady 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.

Source: TROUBLESHOOTING.md16 sections
DEPANNAGE.md

🚑Dépannage

Symptômes et correctifs du lab, de l'hôte jusqu'aux pods. Retour au chemin d'installation : LISEZ-MOI.md · couche applicative : _k8s/LISEZ-MOI.md · mises à jour : talos/MISE-A-JOUR.md.

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.


🖥️ 1. Hôte et VirtualBox#

Conflit VT-x : décharger KVM avant de lancer VirtualBox#

VirtualBox et KVM ne peuvent pas utiliser VT-x en même temps. Si le module noyau KVM est chargé, vagrant up échoue au démarrage :

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.

Vérifier, puis décharger KVM (demande un vrai terminal : sudo réclame un mot de passe) :

# 1. KVM est-il chargé ? (Intel : kvm_intel ; AMD : kvm_amd)
lsmod | grep kvm

# 2. Décharger (échoue si une VM KVM/libvirt tourne encore — l'arrêter d'abord)
sudo modprobe -r kvm_intel kvm      # AMD : sudo modprobe -r kvm_amd kvm

💡 Persistance. KVM est rechargé à chaque démarrage. Si cet hôte ne sert jamais à KVM/libvirt, blackliste-le une fois pour toutes :

echo -e "blacklist kvm_intel\nblacklist kvm" | sudo tee /etc/modprobe.d/disable-kvm.conf

Pour revenir en arrière : supprimer ce fichier et redémarrer (ou sudo modprobe kvm_intel).

VirtualBox refuse le réseau 192.168.56.0/24#

Autoriser la plage dans /etc/vbox/networks.conf :

* 192.168.56.0/21

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ÈS vagrant 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 chaque destroy, 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.


🗺️ 2. Adressage et DHCP#

Un node n'obtient pas son IP .x#

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 = falsetrue 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à acked avant 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)
for v in talos-cp1 talos-cp2 talos-cp3; do VBoxManage controlvm "$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*
VBoxManage dhcpserver restart --network HostInterfaceNetworking-vboxnet0

# 3. rallumer : les nodes refont un DHCP DISCOVER et obtiennent leur IP réservée
vagrant up

Vérification : talosctl -n 192.168.56.10 version --insecure doit répondre NODE: 192.168.56.10.

La VIP 192.168.56.5 est injoignable#

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.


🐧 3. Nodes Talos#

talosctl ... --insecure ne répond pas#

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.

La console Talos affiche KUBERNETES: n/a#

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.

Le disque d'installation n'est pas /dev/sda#

Vérifier avec talosctl -n <ip> get disks --insecure et ajuster INSTALL_DISK.


☸️ 4. Cluster et pods#

Les pods pingent Internet mais n'ont pas de DNS#

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.

kubectl get nodes -o custom-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 :

kubectl -n kube-system patch ds kube-flannel --type=json \
  -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--iface-can-reach=192.168.56.1"}]'
kubectl -n kube-system rollout status ds/kube-flannel

ℹ️ 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.

Les nodes restent NotReady après le bootstrap#

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 toujours NotReady 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.

⚡ Quick start#

# 1. Bootstrap with CNI=cilium (the repo default): Talos installs no CNI, this layer does
CNI=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.

🌐 Choosing the CNI#

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.

CNI= Who installs the CNI LoadBalancer IP (L2) Usable for this layer
cilium (default) platform-up.shcilium/ ✅ pool + ARP announcement ✅ yes
calico platform-up.shcalico/ ❌ BGP only ⚠️ MetalLB required on top
flannel Talos, at bootstrap time ❌ no
none you depends on what you install

⚠️ Switching CNI on an existing cluster is not supported: vagrant destroy, then rebuild. Two CNIs at once fight over the pod network.

🔗 Dependency chain#

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.

📋 Cross-cutting prerequisites#

Prerequisite Why Verify
Cluster Ready, KUBECONFIG set every script probes /readyz kubectl get nodes
kubectl + helm chart installs helm version
main-gateway in place every UI exposed over HTTPS goes through it kubectl get gateway -n envoy-gateway-system
openssl on the host — only if SELF_SIGNED=true (the default) generates the wildcard TLS openssl version
CLOUDFLARE_API_TOKEN in lab.envonly 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/.

🌐 LAB_DOMAIN — the UI domain#

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:

sed "s/talos\.lab\.example\.io/${LAB_DOMAIN}/g" <manifest> | kubectl apply -f -

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:

sed 's/talos\.lab\.example\.io/talos.lab.my-domain.tld/g' <file> | kubectl apply -f -

ℹ️ 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_ISSUERstaging (default, untrusted cert) or prod (trusted, but capped at 5 certificates per week for the same wildcard, and every vagrant destroy burns one).

🧱 Base platform — platform-up.sh#

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.

🗂️ The addons#

Suggested order: storage first (everything else depends on it), then the databases, then the rest in any order.

💾 Storage#

Directory Purpose Install StorageClass provided
longhorn/ distributed block storage; Talos prerequisites (iscsi extensions, rshared mount) longhorn-up.sh longhorn, longhorn-r1
local-path-storage/ dynamic local storage (hostPath), without Longhorn local-path-up.sh local-path
minio-s3/ S3 object storage + console, 1 node minio-up.sh — (consumes local-path)
minio-s3/cluster/ distributed MinIO, 4 nodes, EC:2 erasure coding — the backup target minio-cluster-up.sh — (consumes local-path)

🐘 Databases#

Directory Purpose Install Prerequisites
cloudnative-pg/ PostgreSQL HA operator 0.29.0 (app v1.30.0) + 3-node demo cluster, automatic failover, S3 backups + PITR cloudnative-pg-up.sh SC longhorn-r1; backups → minio-s3/cluster

🔐 Secrets#

Directory Purpose Install Prerequisites
vault-cluster/ HashiCorp Vault HA (Raft), 3 nodes, UI/API under vault.talos.lab.example.io vault-up.sh SC longhorn; manual unsealing
vault-secret-operator/ Vault secrets → native K8s Secrets (static KV, dynamic DB, PKI) — both the Vault and the K8s side Helm + vault/ scripts vault-cluster unsealed

📈 Observability#

Directory Purpose Install Prerequisites
observability/ kube-prometheus-stack 87.19.0 + Loki 7.1.0 + Alloy 1.11.0; grafana / prometheus / alertmanager UIs observability-up.sh SC longhorn-r1; CP ≥ 4 GB
node-problem-detector/ node health (kernel, runtime) 2.3.14, tuned for Talos node-problem-detector-up.sh
chaos-kube/ chaos engineering: chaoskube 0.39.0 deletes 1 random pod/hour, except kube-system, longhorn-system, vault, cnpg-demo chaoskube-up.sh

🛡️ Security#

Directory Purpose Install Prerequisites
kyverno/ policy engine 3.8.2 (app v1.18.2) + Policy Reporter 3.8.1 (UI), teaching policies in Audit mode kyverno-up.sh main-gateway
trivy-operator/ continuous scanner 0.34.0 (CVEs, config, secrets, RBAC); reports in the Policy Reporter UI trivy-operator-up.sh kyverno (shared UI)

🌐 Networking & TLS#

Directory Purpose Install
cilium/ default CNI 1.19.6 + LoadBalancer IP pool + L2 announcement (ARP) cilium-up.sh, called by platform-up.sh when CNI=cilium
calico/ alternative CNI v3.32.1 (Tigera operator) — CNI only, no L2 announcement calico-up.sh, called by platform-up.sh when CNI=calico
envoy-gateway/ Envoy Gateway controller + main-gateway (:80 and :443 wildcard) + demo apps platform-up.sh
self-signed/ default TLS mode — wildcard signed by a local CA (openssl), no domain and no token needed selfsigned-up.sh, called by platform-up.sh when SELF_SIGNED=true
cert-manager/ automatic wildcard TLS certificates (ACME DNS-01 Cloudflare) platform-up.sh, when SELF_SIGNED=false

🧪 Demos#

Directory Purpose Install
argocd/ Argo CD 10.2.1 (GitOps), UI under argo.talos.lab.example.io argocd-up.sh
wordpress-example/ WordPress + MariaDB on Longhorn, exposed through Envoy kubectl apply
databasement/ (local addon, not versioned — see .gitignore) databasement-up.sh

🌍 Remote access (Tailscale + Cloudflare)#

The .200 VIP is a host-only IP announced over ARP: reachable from the host, not routable as-is.

  1. L3 — the host advertises the route:

    sudo tailscale up --advertise-routes=192.168.56.200/32
    

    Then approve it in the Tailscale console.

    ⚠️ Stay on the /32 (or fence it with an ACL): a /24 would also expose the Talos (:50000) and Kubernetes (:6443) APIs.

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

⚠️ Pitfalls#

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

📚 References#

Source: _k8s/README.md17 sections
_k8s/LISEZ-MOI.md

☸️_k8s/ — la couche applicative du lab

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.

⚡ Démarrage rapide#

# 1. Bootstrap en CNI=cilium (défaut du dépôt) : Talos ne pose aucun CNI, cette couche si
CNI=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.

🌐 Le choix du CNI#

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.

CNI= Qui pose le CNI IP LoadBalancer (L2) Utilisable pour cette couche
cilium (défaut) platform-up.shcilium/ ✅ pool + annonce ARP ✅ oui
calico platform-up.shcalico/ ❌ BGP seulement ⚠️ MetalLB requis en plus
flannel Talos, au bootstrap ❌ non
none toi selon ce que tu installes

⚠️ Changer de CNI sur un cluster existant n'est pas supporté : vagrant destroy puis reconstruire. Deux CNI simultanés se disputent le réseau des pods.

🔗 Chaîne de dépendances#

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.

📋 Prérequis transverses#

Prérequis Pourquoi Vérifier
Cluster Ready, KUBECONFIG posé tous les scripts testent /readyz kubectl get nodes
kubectl + helm install des charts helm version
main-gateway en place toute UI exposée en HTTPS passe par lui kubectl get gateway -n envoy-gateway-system
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.envseulement 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/.

🌐 LAB_DOMAIN — le domaine des UI#

Le dépôt est public : tous les manifestes versionnés portent un domaine neutre, talos.lab.example.io. Mets le tien dans lab.env (gitignoré) :

echo 'LAB_DOMAIN=talos.lab.mon-domaine.tld' >> lab.env

Les scripts *-up.sh (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) lisent LAB_DOMAIN — variable d'environnement d'abord, sinon lab.env, sinon le défaut neutre — et substituent le domaine à la volée :

sed "s/talos\.lab\.example\.io/${LAB_DOMAIN}/g" <manifeste> | kubectl apply -f -

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 :

sed 's/talos\.lab\.example\.io/talos.lab.mon-domaine.tld/g' <fichier> | kubectl apply -f -

ℹ️ 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_ISSUERstaging (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).

🧱 Plateforme de base — platform-up.sh#

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.

🗂️ Les addons#

Ordre conseillé : le stockage d'abord (tout le reste en dépend), puis les bases, puis le reste dans n'importe quel ordre.

💾 Stockage#

Dossier Rôle Installation StorageClass fournie
longhorn/ stockage bloc distribué ; prérequis Talos (extensions iscsi, montage rshared) longhorn-up.sh longhorn, longhorn-r1
local-path-storage/ stockage local dynamique (hostPath), sans Longhorn local-path-up.sh local-path
minio-s3/ stockage objet S3 + console, 1 nœud minio-up.sh — (consomme local-path)
minio-s3/cluster/ MinIO distribué 4 nœuds, erasure coding EC:2 — cible des backups minio-cluster-up.sh — (consomme local-path)

🐘 Bases de données#

Dossier Rôle Installation Prérequis
cloudnative-pg/ opérateur PostgreSQL HA 0.29.0 (app v1.30.0) + cluster de démo 3 nœuds, failover auto, backups S3 + PITR cloudnative-pg-up.sh SC longhorn-r1 ; backups → minio-s3/cluster

🔐 Secrets#

Dossier Rôle Installation Prérequis
vault-cluster/ HashiCorp Vault HA (Raft) 3 nœuds, UI/API sous vault.talos.lab.example.io vault-up.sh SC longhorn ; descellement manuel
vault-secret-operator/ secrets Vault → Secret K8s natifs (static KV, dynamic DB, PKI) — côtés Vault et K8s Helm + scripts vault/ vault-cluster descellé

📈 Observabilité#

Dossier Rôle Installation Prérequis
observability/ kube-prometheus-stack 87.19.0 + Loki 7.1.0 + Alloy 1.11.0 ; UI grafana / prometheus / alertmanager observability-up.sh SC longhorn-r1 ; CP ≥ 4 Go
node-problem-detector/ santé des nodes (kernel, runtime) 2.3.14, adapté Talos node-problem-detector-up.sh
chaos-kube/ chaos engineering : chaoskube 0.39.0 supprime 1 pod au hasard/heure, sauf kube-system, longhorn-system, vault, cnpg-demo chaoskube-up.sh

🛡️ Sécurité#

Dossier Rôle Installation Prérequis
kyverno/ policy engine 3.8.2 (app v1.18.2) + Policy Reporter 3.8.1 (UI), policies pédagogiques en Audit kyverno-up.sh main-gateway
trivy-operator/ scanner continu 0.34.0 (CVE, config, secrets, RBAC) ; rapports dans l'UI Policy Reporter trivy-operator-up.sh kyverno (UI partagée)

🌐 Réseau & TLS#

Dossier Rôle Installation
cilium/ CNI par défaut 1.19.6 + pool d'IP LoadBalancer + annonce L2 (ARP) cilium-up.sh, appelé par platform-up.sh si CNI=cilium
calico/ CNI alternatif v3.32.1 (opérateur Tigera) — CNI seul, sans annonce L2 calico-up.sh, appelé par platform-up.sh si CNI=calico
envoy-gateway/ contrôleur Envoy Gateway + main-gateway (:80 et :443 wildcard) + apps de démo platform-up.sh
self-signed/ mode TLS par défaut — wildcard signé par une AC locale (openssl), sans domaine ni token selfsigned-up.sh, appelé par platform-up.sh si SELF_SIGNED=true
cert-manager/ certificats TLS wildcard automatiques (ACME DNS-01 Cloudflare) platform-up.sh, si SELF_SIGNED=false

🧪 Démos#

Dossier Rôle Installation
argocd/ Argo CD 10.2.1 (GitOps), UI sous argo.talos.lab.example.io argocd-up.sh
wordpress-example/ WordPress + MariaDB sur Longhorn, exposé via Envoy kubectl apply
databasement/ (addon local, non versionné — cf. .gitignore) databasement-up.sh

🌍 Accès distant (Tailscale + Cloudflare)#

Le VIP .200 est une IP host-only annoncée en ARP : joignable depuis l'hôte, pas routable telle quelle.

  1. L3 — l'hôte annonce la route :

    sudo tailscale up --advertise-routes=192.168.56.200/32
    

    Puis approuver dans la console Tailscale.

    ⚠️ Reste sur le /32 (ou cadre par ACL) : un /24 exposerait aussi les API Talos (:50000) et Kubernetes (:6443).

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

⚠️ Pièges#

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

📚 Références#

Source : _k8s/LISEZ-MOI.md17 sections
_k8s/cilium/README.md

🐝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 network 192.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.

🎯 Purpose#

  • CNI in VXLAN tunnel mode, pinned to the host-only interface (see ⚠️ Pitfalls).
  • LoadBalancer IPs: a .200-.230 pool stands in for the missing cloud provider.
  • L2 announcement (ARP): the IP becomes reachable from the host, hence over Tailscale (see ../README.md, "Remote access" section).
  • Network observability: Hubble (relay + UI) is enabled, handy for showing flows.

📋 Prerequisites#

Prerequisite Why Check
Cluster bootstrapped with CNI=cilium (talos/cluster-up.sh, the default — CNI=none gives the same machine config) Talos must install no CNI at all: Cilium takes that slot kubectl get nodesNotReady before the install, that is expected
Host-only interface named enp0s8 source of the ARP announcement and of the VXLAN tunnels talosctl -n 192.168.56.10 get links
kubectl + helm, KUBECONFIG set the script checks the binaries, then /readyz helm version

⚡ Install#

./_k8s/cilium/cilium-up.sh

Chart cilium/cilium 1.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.

🔧 What the script does#

  1. Installs Cilium with Helm in kube-system, with the Talos-specific + L2 values;
  2. waits for condition=Ready on every node (300 s max) — the CNI is what unblocks them;
  3. applies cilium-l2.yml: LoadBalancer IP pool + ARP announcement policy.

The --set flags that matter#

Setting Why
devices=enp0s8 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
cgroup.autoMount.enabled=false + cgroup.hostRoot=/sys/fs/cgroup Talos adaptation: the cgroupfs is already mounted by the OS
securityContext.capabilities.* capabilities listed explicitly (agent and cleanCiliumState) instead of privileged mode: this is the configuration documented by Talos
hubble.* + bandwidthManager.enabled=true flow observability + bandwidth management (demos)

cilium-l2.yml — two objects#

Object Role
CiliumLoadBalancerIPPool lb-pool-56 reserves the .200.230 range; every LoadBalancer Service draws from it
CiliumL2AnnouncementPolicy l2-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.

✅ Verify#

kubectl -n kube-system get pods -l k8s-app=cilium              # one agent per node, Running
kubectl get nodes                                              # all Ready
kubectl get ciliumloadbalancerippool                           # lb-pool-56, DISABLED=false, IPS AVAILABLE
kubectl get ciliuml2announcementpolicy                         # l2-lb-workers
kubectl -n envoy-gateway-system get svc                        # EXTERNAL-IP = 192.168.56.200
ping -c1 192.168.56.200                                        # from the host: ARP must answer

🌐 Hubble UI (not exposed)#

Hubble is enabled but no HTTPRoute exposes it: that is deliberate (the UI has no authentication). One-off access through a port-forward:

kubectl -n kube-system port-forward svc/hubble-ui 12000:80     # then http://localhost:12000

⚠️ Pitfalls#

  • 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.yaml and kubeProxyReplacement=true + k8sServiceHost=192.168.56.5 k8sServicePort=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 v2v2alpha1 is marked deprecated there). Re-check on a major version bump: kubectl get crd ciliuml2announcementpolicies.cilium.io -o yaml.

📚 References#

Source: _k8s/cilium/README.md10 sections
_k8s/cilium/LISEZ-MOI.md

🐝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-only 192.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.

🎯 À quoi ça sert#

  • CNI en mode tunnel VXLAN, épinglé sur l'interface host-only (cf. ⚠️ Pièges).
  • IP LoadBalancer : un pool .200-.230 remplace le cloud provider absent.
  • Annonce L2 (ARP) : l'IP devient joignable depuis l'hôte, donc via Tailscale (cf. ../LISEZ-MOI.md, section « Accès distant »).
  • Observabilité réseau : Hubble (relay + UI) est activé, pratique pour montrer les flux.

📋 Prérequis#

Prérequis Pourquoi Vérifier
Cluster bootstrapé avec CNI=cilium (talos/cluster-up.sh, le défaut — CNI=none donne la même machine config) Talos ne doit installer aucun CNI : c'est Cilium qui prend la place kubectl get nodesNotReady avant l'install, c'est normal
Interface host-only nommée enp0s8 source de l'annonce ARP et des tunnels VXLAN talosctl -n 192.168.56.10 get links
kubectl + helm, KUBECONFIG posé le script vérifie les binaires puis /readyz helm version

⚡ Installation#

./_k8s/cilium/cilium-up.sh

Chart cilium/cilium 1.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.

🔧 Ce que fait le script#

  1. Installe Cilium en Helm dans kube-system avec les valeurs spécifiques Talos + L2 ;
  2. attend condition=Ready sur tous les nodes (300 s max) — c'est le CNI qui les débloque ;
  3. applique cilium-l2.yml : pool d'IP LoadBalancer + politique d'annonce ARP.

Les --set qui comptent#

Réglage Pourquoi
devices=enp0s8 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
cgroup.autoMount.enabled=false + cgroup.hostRoot=/sys/fs/cgroup adaptation Talos : le cgroupfs est déjà monté par l'OS
securityContext.capabilities.* capabilities listées explicitement (agent et cleanCiliumState) au lieu du mode privilégié : c'est la configuration documentée par Talos
hubble.* + bandwidthManager.enabled=true observabilité des flux + gestion de bande passante (démos)

cilium-l2.yml — deux objets#

Objet Rôle
CiliumLoadBalancerIPPool lb-pool-56 réserve la plage .200.230 ; chaque Service LoadBalancer pioche dedans
CiliumL2AnnouncementPolicy l2-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.

✅ Vérifier#

kubectl -n kube-system get pods -l k8s-app=cilium              # un agent par node, Running
kubectl get nodes                                              # tous Ready
kubectl get ciliumloadbalancerippool                           # lb-pool-56, DISABLED=false, IPS AVAILABLE
kubectl get ciliuml2announcementpolicy                         # l2-lb-workers
kubectl -n envoy-gateway-system get svc                        # EXTERNAL-IP = 192.168.56.200
ping -c1 192.168.56.200                                        # depuis l'hôte : l'ARP doit répondre

🌐 Hubble UI (non exposée)#

Hubble est activé mais aucune HTTPRoute ne l'expose : c'est volontaire (l'UI n'a pas d'authentification). Accès ponctuel par port-forward :

kubectl -n kube-system port-forward svc/hubble-ui 12000:80     # puis http://localhost:12000

⚠️ Pièges#

  • 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.yaml et kubeProxyReplacement=true + k8sServiceHost=192.168.56.5 k8sServicePort=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 v2v2alpha1 y est marqué déprécié). À revérifier lors d'une montée de version majeure : kubectl get crd ciliuml2announcementpolicies.cilium.io -o yaml.

📚 Références#

Source : _k8s/cilium/LISEZ-MOI.md10 sections
_k8s/calico/README.md

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

🎯 Purpose#

What Calico does here#

  • 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=calico and nothing else,

What happens Visible effect
The Envoy Gateway Service never gets an external IP EXTERNAL-IP <pending>
The main-gateway Gateway has no address empty status.addresses
The HTTPRoutes are reachable by nobody Argo CD, Grafana, Vault, Longhorn, MinIO… unreachable
The wildcard certificate is still issued (DNS-01) but is useless: there is no entry point left

In other words: the whole _k8s/ layer of the lab depends on that VIP. See 🌐 Making the UIs reachable for the procedure.

Cilium or Calico?#

Capability Cilium (_k8s/cilium/) Calico (this directory)
CNI (pod network, routing) ✅ VXLAN, enp0s8 interface pinned ✅ VXLAN, autodetection on 192.168.56.0/24
Kubernetes NetworkPolicy
Extended policies CiliumNetworkPolicy (L7, DNS, identities) projectcalico.org/v3 (tiers, order, HostEndpoint)
L2 announcement of LoadBalancer IPs ✅ built in (ARP, .200-.230 pool) MetalLB required (Calico = BGP only)
kube-proxy replacement kubeProxyReplacement=true (documented) ⚠️ 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.

📋 Prerequisites#

Prerequisite Why Check
Cluster bootstrapped without a CNI (CNI=calico in lab.env, see talos/cni-calico.yaml) Talos must install neither flannel nor anything else: Calico takes that slot kubectl get nodes → all NotReady before the install, that is expected
No other CNI present two CNIs fight over /etc/cni/net.d and the routes; there is no clean way back the script refuses to run if it sees daemonset/cilium or kube-flannel in kube-system
Talos podSubnets == IPPool CIDR otherwise the kubelet allocates pod IPs that Calico has not programmed grep -A2 podSubnets _out/controlplane.yaml10.244.0.0/16
A host-only address on every node source of Calico's address autodetection kubectl get nodes -o wideINTERNAL-IP in 192.168.56.x
kubectl + helm, KUBECONFIG set the script checks the binaries, then /readyz helm version

⚡ Install#

./_k8s/calico/calico-up.sh

Chart projectcalico/tigera-operator v3.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

🔧 What the script does#

  1. Guardrails: binaries, /readyz, refusal if another CNI is already there, and refusal if POD_CIDR diverges from the podSubnets read in _out/controlplane.yaml.
  2. 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).
  3. tigera-operator chart in that namespace. The operator runs in hostNetwork: it starts without a CNI, which is what makes bootstrapping possible.
  4. Waits for the operator.tigera.io CRDs: the operator is started with -manage-crds=true, so it is the one creating installations and apiservers.operator.tigera.io. Applying a CR before that fails with "no matches for kind …".
  5. 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.
  6. 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.
  7. Summary + a yellow reminder of the two steps still missing for the lab UIs.

The Helm settings that matter#

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 and kubectl apply)
apiServer.enabled=false same reason, CR moved to apiserver.yaml and applied once the CRDs exist. The calico-apiserver is 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 installation.yaml fields that matter#

Field Value Why
calicoNetwork.nodeAddressAutodetectionV4.cidrs ["192.168.56.0/24"] 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

✅ Verify#

kubectl -n tigera-operator get pods                       # tigera-operator Running
kubectl get tigerastatus                                  # calico / apiserver: AVAILABLE=True
kubectl -n calico-system get pods -o wide                 # one calico-node per node + typha
kubectl get nodes                                         # all Ready
kubectl get installation default -o yaml                  # the CR as the operator completed it
kubectl get ippools.projectcalico.org default-ipv4-ippool -o yaml   # cidr + vxlanMode Always

The check that really matters: the address picked by each node must be in 192.168.56.x, never 10.0.2.15.

COLS='NODE:.metadata.name'
COLS="$COLS,ADDR:.metadata.annotations.projectcalico\.org/IPv4Address"
COLS="$COLS,VXLAN:.metadata.annotations.projectcalico\.org/IPv4VXLANTunnelAddr"
kubectl get nodes -o "custom-columns=$COLS"

Then a cross-node traffic test (this is where the NAT NIC pitfall shows up):

kubectl run t1 --image=busybox --restart=Never --command -- sleep 3600
kubectl run t2 --image=busybox --restart=Never --command -- sleep 3600
kubectl get pods -o wide                                  # check they are on 2 different nodes
kubectl exec t1 -- ping -c3 "$(kubectl get pod t2 -o jsonpath='{.status.podIP}')"
kubectl exec t1 -- nslookup kubernetes.default            # DNS = CoreDNS, often on another node
kubectl delete pod t1 t2

🌐 Making the lab UIs reachable (MetalLB)#

Calico provides no LoadBalancer IP: installing an L2 announcer is on you. This directory does not do it. Two things to do, in this order.

1. Install MetalLB in L2 mode on the same range Cilium uses (192.168.56.200192.168.56.230, with the first IP going to main-gateway):

helm repo add metallb https://metallb.github.io/metallb
helm upgrade --install metallb metallb/metallb --version 0.16.1 \
  -n metallb-system --create-namespace

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.

2. Remove the Cilium-specific loadBalancerClass. ../envoy-gateway/Envoy-Proxy.yml currently pins, on line 13:

        loadBalancerClass: io.cilium/l2-announcer

⚠️ 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 -n envoy-gateway-system get svc                   # EXTERNAL-IP = 192.168.56.200
ping -c1 192.168.56.200                                   # from the host: ARP must answer

⚠️ Pitfalls#

  • 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 destroyCNI=calico in lab.envvagrant 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.

🚑 Troubleshooting#

Symptom Likely cause What to do
The script fails on "another CNI is already installed" you are re-running Calico on the lab's Cilium (or flannel) cluster that is the guardrail: rebuild the cluster, no live switch
helm fails on no matches for kind "APIServer" / ensure CRDs are installed first a chart CR is enabled while its CRD does not exist yet keep the four CRs disabled (see the --set table); the CRs live in installation.yaml / apiserver.yaml
rollout status times out and get pods shows zero pod in tigera-operator PodSecurity baseline rejects the operator (hostNetwork + hostPath) kubectl -n tigera-operator describe rs to confirm, apply namespace.yaml, then rollout restart
CRD installations.operator.tigera.io never created the operator cannot reach the apiserver, or never started kubectl -n tigera-operator logs deploy/tigera-operator
calico-node in Init: / CreateContainerConfigError a read-only hostPath (typically the flexvol-driver if flexVolumePath was dropped) check kubectl get installation default -o yamlflexVolumePath: None
Nodes Ready but DNS broken from a pod NAT address elected for the tunnels re-read the first ⚠️ Pitfalls bullet, then the annotations command in ✅ Verify
kubectl get tigerastatusDegraded the operator explains why in the message kubectl get tigerastatus calico -o yaml
Gateway at EXTERNAL-IP <pending> expected without MetalLB 🌐 section, both steps
Pods Pending with no IP addresses available in range the /26 block on that node is exhausted, or the IPPool is too small kubectl get ipamblocks.crd.projectcalico.org

🧹 Uninstall#

The chart ships a pre-delete hook (Job tigera-operator-uninstall) that cleans up the CR before removing the operator:

helm uninstall calico -n tigera-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 destroyCNI=ciliumcluster-up.sh_k8s/platform-up.sh).

📚 References#

Source: _k8s/calico/README.md15 sections
_k8s/calico/LISEZ-MOI.md

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

🎯 À quoi ça sert#

Ce que Calico fait ici#

  • 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=calico et rien d'autre,

Ce qui se passe Effet visible
Le Service du Gateway Envoy ne reçoit jamais d'IP externe EXTERNAL-IP <pending>
La Gateway main-gateway n'a pas d'adresse status.addresses vide
Les HTTPRoute ne sont joignables par personne Argo CD, Grafana, Vault, Longhorn, MinIO… inaccessibles
Le certificat wildcard s'émet quand même (DNS-01) mais ne sert à rien : plus de point d'entrée

Autrement dit : toute la couche _k8s/ du lab dépend de ce VIP. Voir 🌐 Rendre les UI joignables pour la marche à suivre.

Cilium ou Calico ?#

Capacité Cilium (_k8s/cilium/) Calico (ce dossier)
CNI (réseau pod, routage) ✅ VXLAN, interface enp0s8 épinglée ✅ VXLAN, autodétection sur 192.168.56.0/24
NetworkPolicy Kubernetes
Policies étendues CiliumNetworkPolicy (L7, DNS, identités) projectcalico.org/v3 (tiers, ordre, HostEndpoint)
Annonce L2 des IP LoadBalancer ✅ intégrée (ARP, pool .200-.230) MetalLB obligatoire (Calico = BGP uniquement)
Remplacement de kube-proxy kubeProxyReplacement=true (documenté) ⚠️ 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.

📋 Prérequis#

Prérequis Pourquoi Vérifier
Cluster bootstrapé sans CNI (CNI=calico dans lab.env, cf. talos/cni-calico.yaml) Talos ne doit installer ni flannel ni rien d'autre : Calico prend la place kubectl get nodes → tous NotReady avant l'install, c'est normal
Aucun autre CNI présent deux CNI se disputent /etc/cni/net.d et les routes ; pas de retour en arrière propre le script refuse de tourner s'il voit daemonset/cilium ou kube-flannel dans kube-system
podSubnets de Talos == CIDR de l'IPPool sinon kubelet alloue des IP de pod que Calico n'a pas programmées grep -A2 podSubnets _out/controlplane.yaml10.244.0.0/16
Adresse host-only sur chaque node source de l'autodétection d'adresse Calico kubectl get nodes -o wideINTERNAL-IP en 192.168.56.x
kubectl + helm, KUBECONFIG posé le script vérifie les binaires puis /readyz helm version

⚡ Installation#

./_k8s/calico/calico-up.sh

Chart projectcalico/tigera-operator v3.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

🔧 Ce que fait le script#

  1. 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.
  2. 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).
  3. 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.
  4. Attente des CRD operator.tigera.io : l'opérateur est lancé avec -manage-crds=true, c'est donc lui qui crée installations et apiservers.operator.tigera.io. Appliquer une CR avant ça échoue sur « no matches for kind … ».
  5. 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.
  6. 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.
  7. Résumé + rappel en jaune des deux étapes manquantes pour les UI du lab.

Les réglages Helm qui comptent#

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 et kubectl apply)
apiServer.enabled=false même raison, CR sortie dans apiserver.yaml et appliquée une fois les CRD présentes. Le calico-apiserver est 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

Les champs de installation.yaml qui comptent#

Champ Valeur Pourquoi
calicoNetwork.nodeAddressAutodetectionV4.cidrs ["192.168.56.0/24"] 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

✅ Vérifier#

kubectl -n tigera-operator get pods                       # tigera-operator Running
kubectl get tigerastatus                                  # calico / apiserver : AVAILABLE=True
kubectl -n calico-system get pods -o wide                 # un calico-node par node + typha
kubectl get nodes                                         # tous Ready
kubectl get installation default -o yaml                  # la CR telle que l'opérateur l'a complétée
kubectl get ippools.projectcalico.org default-ipv4-ippool -o yaml   # cidr + vxlanMode Always

Le contrôle qui compte vraiment : l'adresse retenue par chaque node doit être en 192.168.56.x, jamais 10.0.2.15.

COLS='NODE:.metadata.name'
COLS="$COLS,ADDR:.metadata.annotations.projectcalico\.org/IPv4Address"
COLS="$COLS,VXLAN:.metadata.annotations.projectcalico\.org/IPv4VXLANTunnelAddr"
kubectl get nodes -o "custom-columns=$COLS"

Puis un test de trafic cross-node (c'est là que se voit le piège de la carte NAT) :

kubectl run t1 --image=busybox --restart=Never --command -- sleep 3600
kubectl run t2 --image=busybox --restart=Never --command -- sleep 3600
kubectl get pods -o wide                                  # vérifie qu'ils sont sur 2 nodes différents
kubectl exec t1 -- ping -c3 "$(kubectl get pod t2 -o jsonpath='{.status.podIP}')"
kubectl exec t1 -- nslookup kubernetes.default            # DNS = CoreDNS, souvent sur un autre node
kubectl delete pod t1 t2

🌐 Rendre les UI du lab joignables (MetalLB)#

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.200192.168.56.230, la première IP revenant à main-gateway) :

helm repo add metallb https://metallb.github.io/metallb
helm upgrade --install metallb metallb/metallb --version 0.16.1 \
  -n metallb-system --create-namespace

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.

2. Retirer la loadBalancerClass spécifique à Cilium. ../envoy-gateway/Envoy-Proxy.yml épingle aujourd'hui, ligne 13 :

        loadBalancerClass: io.cilium/l2-announcer

⚠️ 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 -n envoy-gateway-system get svc                   # EXTERNAL-IP = 192.168.56.200
ping -c1 192.168.56.200                                   # depuis l'hôte : l'ARP doit répondre

⚠️ Pièges#

  • 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'IPPoolpodSubnets 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 destroyCNI=calico dans lab.envvagrant 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.

🚑 Dépannage#

Symptôme Cause probable Quoi faire
Le script échoue sur « un autre CNI est déjà installé » tu relances Calico sur le cluster Cilium (ou flannel) du lab c'est le garde-fou : rebuild du cluster, pas de bascule à chaud
helm échoue sur no matches for kind "APIServer" / ensure CRDs are installed first une CR du chart est activée alors que sa CRD n'existe pas encore garder les quatre CR coupées (cf. le tableau des --set) ; les CR vivent dans installation.yaml / apiserver.yaml
rollout status en timeout et get pods montre zéro pod dans tigera-operator PodSecurity baseline refuse l'opérateur (hostNetwork + hostPath) kubectl -n tigera-operator describe rs pour confirmer, appliquer namespace.yaml, puis rollout restart
CRD installations.operator.tigera.io jamais créée l'opérateur ne joint pas l'apiserver ou n'a pas démarré kubectl -n tigera-operator logs deploy/tigera-operator
calico-node en Init: / CreateContainerConfigError un hostPath en lecture seule (typiquement le flexvol-driver si flexVolumePath a été retiré) vérifie kubectl get installation default -o yamlflexVolumePath: None
Nodes Ready mais DNS KO depuis un pod adresse NAT élue pour les tunnels relis la 1re puce des ⚠️ Pièges, puis la commande d'annotations de ✅ Vérifier
kubectl get tigerastatusDegraded l'opérateur explique pourquoi dans le message kubectl get tigerastatus calico -o yaml
Gateway en EXTERNAL-IP <pending> normal sans MetalLB section 🌐, les deux étapes
Pods Pending avec no IP addresses available in range bloc /26 épuisé sur ce node ou IPPool trop petit kubectl get ipamblocks.crd.projectcalico.org

🧹 Désinstaller#

Le chart embarque un hook pre-delete (Job tigera-operator-uninstall) qui nettoie la CR avant de retirer l'opérateur :

helm uninstall calico -n tigera-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 destroyCNI=ciliumcluster-up.sh_k8s/platform-up.sh).

📚 Références#

Source : _k8s/calico/LISEZ-MOI.md15 sections
_k8s/envoy-gateway/README.md

🚪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-gateway Gateway exposes :80 and :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.

🎯 Purpose#

  • 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: GatewayClassGatewayHTTPRoute, 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.

📋 Prerequisites#

Prerequisite Why Check
../cilium/ installed (L2 pool) 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/....

⚡ Install#

The controller is installed by the platform, step [2/4]:

./_k8s/platform-up.sh

OCI chart oci://docker.io/envoyproxy/gateway-helm 1.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)
helm upgrade --install eg oci://docker.io/envoyproxy/gateway-helm \
  --version 1.8.3 -n envoy-gateway-system --create-namespace
kubectl -n envoy-gateway-system rollout status deploy/envoy-gateway
kubectl apply -f _k8s/envoy-gateway/Envoy-Proxy.yml

🔧 Envoy-Proxy.yml — the plumbing#

Object Role
EnvoyProxy cilium-l2 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
GatewayClass envoy class managed by gateway.envoyproxy.io/gatewayclass-controller, pointing at the EnvoyProxy above
Gateway main-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.

Data plane: 2 replicas#

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.

kubectl -n envoy-gateway-system get deploy \
  -l gateway.envoyproxy.io/owning-gateway-name=main-gateway    # expect 2/2

ℹ️ 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)#

Listener Port Hostname TLS
http 80 (none — any hostname)
https 443 *.talos.lab.example.io Terminate, certificateRefs: wildcard-talos-lab-example-io-tls

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.

Attaching an application#

This is the only work left for a new component: an HTTPRoute targeting the TLS listener.

spec:
  parentRefs:
    - name: main-gateway
      namespace: envoy-gateway-system
      sectionName: https           # targets the :443 listener (without it, BOTH listeners)
  hostnames:
    - my-app.talos.lab.example.io      # must match the wildcard *.talos.lab.example.io
  rules:
    - backendRefs:
        - name: my-app
          port: 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.

✅ Verify#

kubectl -n envoy-gateway-system get svc        # EXTERNAL-IP = 192.168.56.200 (otherwise → ../cilium/)
kubectl get gateway -n envoy-gateway-system    # main-gateway, PROGRAMMED=True, ADDRESS=.200
kubectl get httproute -A                       # every route in the lab
# listeners + number of routes attached to each:
kubectl -n envoy-gateway-system get gateway main-gateway \
  -o jsonpath='{range .status.listeners[*]}{.name}{" attached="}{.attachedRoutes}{"\n"}{end}'
# the cert served for a hostname of the wildcard:
echo | openssl s_client -connect 192.168.56.200:443 -servername demo.talos.lab.example.io 2>/dev/null \
  | openssl x509 -noout -subject -issuer

🧪 GW-Example.yml — the demo (optional)#

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
kubectl apply -f _k8s/envoy-gateway/GW-Example.yml       # namespace `default`
curl -sS http://192.168.56.200/hello
curl -sS http://192.168.56.200/echo
kubectl delete -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/hello200). On the other hand https://hello.talos.lab.example.io/ returns 404: the match is on the path, not on the hostname.

⚠️ Pitfalls#

  • 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).

📚 References#

Source: _k8s/envoy-gateway/README.md11 sections
_k8s/envoy-gateway/LISEZ-MOI.md

🚪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 Gateway main-gateway y expose :80 et :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.

🎯 À quoi ça sert#

  • 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 : GatewayClassGatewayHTTPRoute, 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.

📋 Prérequis#

Prérequis Pourquoi Vérifier
../cilium/ installé (pool L2) 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/....

⚡ Installation#

Le contrôleur est installé par la plateforme, étape [2/4] :

./_k8s/platform-up.sh

Chart OCI oci://docker.io/envoyproxy/gateway-helm 1.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)
helm upgrade --install eg oci://docker.io/envoyproxy/gateway-helm \
  --version 1.8.3 -n envoy-gateway-system --create-namespace
kubectl -n envoy-gateway-system rollout status deploy/envoy-gateway
kubectl apply -f _k8s/envoy-gateway/Envoy-Proxy.yml

🔧 Envoy-Proxy.yml — la plomberie#

Objet Rôle
EnvoyProxy cilium-l2 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
GatewayClass envoy classe gérée par gateway.envoyproxy.io/gatewayclass-controller, pointant l'EnvoyProxy ci-dessus
Gateway main-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.

Plan de données : 2 réplicas#

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.

kubectl -n envoy-gateway-system get deploy \
  -l gateway.envoyproxy.io/owning-gateway-name=main-gateway    # attendu 2/2

ℹ️ 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.

Les deux écouteurs (déjà câblés, rien à ajouter)#

Écouteur Port Hostname TLS
http 80 (aucun — tout hostname)
https 443 *.talos.lab.example.io Terminate, certificateRefs: wildcard-talos-lab-example-io-tls

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.

Brancher une application#

C'est le seul travail restant pour un nouvel addon : une HTTPRoute qui cible l'écouteur TLS.

spec:
  parentRefs:
    - name: main-gateway
      namespace: envoy-gateway-system
      sectionName: https           # cible l'écouteur :443 (sans ça, les DEUX écouteurs)
  hostnames:
    - mon-app.talos.lab.example.io     # doit matcher le wildcard *.talos.lab.example.io
  rules:
    - backendRefs:
        - name: mon-app
          port: 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.

✅ Vérifier#

kubectl -n envoy-gateway-system get svc        # EXTERNAL-IP = 192.168.56.200 (sinon → ../cilium/)
kubectl get gateway -n envoy-gateway-system    # main-gateway, PROGRAMMED=True, ADDRESS=.200
kubectl get httproute -A                       # toutes les routes du lab
# écouteurs + nombre de routes attachées à chacun :
kubectl -n envoy-gateway-system get gateway main-gateway \
  -o jsonpath='{range .status.listeners[*]}{.name}{" attached="}{.attachedRoutes}{"\n"}{end}'
# le cert servi pour un hostname du wildcard :
echo | openssl s_client -connect 192.168.56.200:443 -servername demo.talos.lab.example.io 2>/dev/null \
  | openssl x509 -noout -subject -issuer

🧪 GW-Example.yml — la démo (optionnelle)#

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
kubectl apply -f _k8s/envoy-gateway/GW-Example.yml       # namespace `default`
curl -sS http://192.168.56.200/hello
curl -sS http://192.168.56.200/echo
kubectl delete -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/hello200). En revanche https://hello.talos.lab.example.io/ renvoie 404 : le match porte sur le chemin, pas sur le nom d'hôte.

⚠️ Pièges#

  • 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).

📚 Références#

Source : _k8s/envoy-gateway/LISEZ-MOI.md11 sections
_k8s/self-signed/README.md

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

🎯 Purpose#

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.

SELF_SIGNED=true (this page) SELF_SIGNED=false (cert-manager/)
Real domain required no yes
CLOUDFLARE_API_TOKEN not used required
Works offline yes no (ACME + DNS-01)
Browser-trusted out of the box no (import the CA once) yes (with LAB_ACME_ISSUER=prod)
Rate limit none 5 certs/week in prod
Auto-renewal in-cluster no (re-run the script) yes (cert-manager)
Survives vagrant destroy yes (CA + cert live on the host) no (wildcard only lives in etcd)

📋 Prerequisites#

Prerequisite Why Check
openssl on the host generates the CA and the certificate openssl version
main-gateway in place (../envoy-gateway/) it is the listener that serves the Secret kubectl get gateway -n envoy-gateway-system
LAB_DOMAIN set in lab.env drives the SAN and the Secret name sed -n 's/^LAB_DOMAIN=//p' lab.env

No DNS zone, no API token, no inbound port. The domain does not have to exist publicly — it only has to resolve on the machine you browse from.

⚡ Install#

It is installed by the platform, step [4/4], whenever SELF_SIGNED=true:

./_k8s/platform-up.sh

Standalone, on an existing platform:

./_k8s/self-signed/selfsigned-up.sh

Idempotent: re-running it reuses the CA and keeps the certificate as long as it is still valid.

🔧 How it works#

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

Why the material lives in _out/#

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

When the certificate is regenerated#

The script rebuilds the leaf (never the CA) when:

  • _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.

Files#

File Role
selfsigned-up.sh generates the CA + leaf, creates the TLS Secret. Called by ../platform-up.sh step [4/4]

Everything it produces is untracked, under _out/self-signed/.

✅ Verify#

kubectl -n envoy-gateway-system get secret wildcard-<domain-in-dashes>-tls   # type kubernetes.io/tls
kubectl -n envoy-gateway-system get gateway main-gateway                     # PROGRAMMED=True
openssl x509 -in _out/self-signed/tls.crt -noout -subject -issuer -dates -ext subjectAltName

# Which certificate does Envoy actually serve?
echo | openssl s_client -connect 192.168.56.200:443 -servername demo.<LAB_DOMAIN> 2>/dev/null \
  | openssl x509 -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 \
  --resolve argo.<LAB_DOMAIN>:443:192.168.56.200 https://argo.<LAB_DOMAIN>/
# expected: 200 verify=0

🌐 Access#

Two things stand between you and a green padlock.

1. Resolve the name. The domain need not exist publicly; /etc/hosts is enough:

192.168.56.200  argo.<LAB_DOMAIN> grafana.<LAB_DOMAIN> vault.<LAB_DOMAIN>

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
sudo cp _out/self-signed/ca.crt /usr/local/share/ca-certificates/vagrant-talos-lab.crt
sudo update-ca-certificates

# macOS
sudo security add-trusted-cert -d -r trustRoot \
  -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.

⚠️ Pitfalls#

  • Switching falsetrue 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 truefalse 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.crt and ca.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.

📚 References#

Source: _k8s/self-signed/README.md11 sections
_k8s/self-signed/LISEZ-MOI.md

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

🎯 Objectif#

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.

SELF_SIGNED=true (cette page) SELF_SIGNED=false (cert-manager/)
Domaine réel nécessaire non oui
CLOUDFLARE_API_TOKEN inutilisé obligatoire
Fonctionne hors-ligne oui non (ACME + DNS-01)
Trusté par le navigateur d'emblée non (importer l'AC une fois) oui (avec LAB_ACME_ISSUER=prod)
Quota aucun 5 certs/semaine en prod
Renouvellement auto dans le cluster non (relancer le script) oui (cert-manager)
Survit à vagrant destroy oui (AC + cert vivent sur l'hôte) non (le wildcard ne vit que dans etcd)

📋 Prérequis#

Prérequis Pourquoi Vérifier
openssl sur l'hôte génère l'AC et le certificat openssl version
main-gateway en place (../envoy-gateway/) c'est l'écouteur qui sert le Secret kubectl get gateway -n envoy-gateway-system
LAB_DOMAIN renseigné dans lab.env pilote le SAN et le nom du Secret sed -n 's/^LAB_DOMAIN=//p' lab.env

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.

⚡ Installation#

Posé par la plateforme, étape [4/4], dès que SELF_SIGNED=true :

./_k8s/platform-up.sh

Seul, sur une plateforme déjà en place :

./_k8s/self-signed/selfsigned-up.sh

Idempotent : relancé, il réutilise l'AC et conserve le certificat tant qu'il est valide.

🔧 Fonctionnement#

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

Pourquoi le matériel vit dans _out/#

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

Quand le certificat est régénéré#

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

Fichiers#

Fichier Rôle
selfsigned-up.sh génère l'AC + la feuille, crée le Secret TLS. Appelé par ../platform-up.sh à l'étape [4/4]

Tout ce qu'il produit est non versionné, sous _out/self-signed/.

✅ Vérifier#

kubectl -n envoy-gateway-system get secret wildcard-<domaine-en-tirets>-tls  # type kubernetes.io/tls
kubectl -n envoy-gateway-system get gateway main-gateway                     # PROGRAMMED=True
openssl x509 -in _out/self-signed/tls.crt -noout -subject -issuer -dates -ext subjectAltName

# Quel certificat Envoy sert-il vraiment ?
echo | openssl s_client -connect 192.168.56.200:443 -servername demo.<LAB_DOMAIN> 2>/dev/null \
  | openssl x509 -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 \
  --resolve argo.<LAB_DOMAIN>:443:192.168.56.200 https://argo.<LAB_DOMAIN>/
# attendu : 200 verify=0

🌐 Accès#

Deux choses te séparent du cadenas vert.

1. Résoudre le nom. Le domaine n'a pas besoin d'exister publiquement ; /etc/hosts suffit :

192.168.56.200  argo.<LAB_DOMAIN> grafana.<LAB_DOMAIN> vault.<LAB_DOMAIN>

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
sudo cp _out/self-signed/ca.crt /usr/local/share/ca-certificates/vagrant-talos-lab.crt
sudo update-ca-certificates

# macOS
sudo security add-trusted-cert -d -r trustRoot \
  -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.

⚠️ Pièges#

  • 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.crt et ca.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.

📚 Références#

Source : _k8s/self-signed/LISEZ-MOI.md11 sections
_k8s/cert-manager/README.md

📜cert-manager/ — automatic wildcard TLS (ACME DNS-01 Cloudflare)

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.

🎯 Purpose#

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.

Why DNS-01, why Let's Encrypt#

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

📋 Prerequisites#

Prerequisite Why Check
main-gateway in place (../envoy-gateway/) that is the object cert-manager watches kubectl get gateway -n envoy-gateway-system
Gateway API CRDs present cert-manager discovers them at startup (installed by the Envoy Gateway chart) kubectl get crd gateways.gateway.networking.k8s.io
example.io zone at Cloudflare, *.talos.lab.example.io → 192.168.56.200 in DNS-only mode the DNS-01 solver writes into that zone dig +short TXT _acme-challenge.talos.lab.example.io
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.

⚡ Install#

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-manager v1.20.2, pinned in ../platform-up.sh (CERT_MANAGER_VERSION). The script:

  1. 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);
  2. creates the cloudflare-api-token Secret from lab.env (it warns and continues if the token is empty — the certificate then stays pending);
  3. applies 02-clusterissuer-staging.yaml and 03-clusterissuer-prod.yaml;
  4. waits for Ready=True on the wildcard-talos-lab-example-io-tls Certificate — a name derived from LAB_DOMAIN (~1-2 min, 24 × 10 s).
Manual equivalent (installing only this component)
helm repo add jetstack https://charts.jetstack.io && helm repo update
# --version: keep the one from platform-up.sh (CERT_MANAGER_VERSION)
helm upgrade --install cert-manager jetstack/cert-manager \
  --namespace cert-manager --create-namespace \
  --version v1.20.2 \
  --set crds.enabled=true \
  --set config.apiVersion="controller.config.cert-manager.io/v1alpha1" \
  --set config.kind="ControllerConfiguration" \
  --set config.enableGatewayAPI=true
kubectl -n cert-manager rollout status deploy/cert-manager
kubectl create secret generic cloudflare-api-token -n cert-manager \
  --from-literal=api-token='<YOUR_TOKEN>'
kubectl apply -f _k8s/cert-manager/02-clusterissuer-staging.yaml \
              -f _k8s/cert-manager/03-clusterissuer-prod.yaml

🔧 How the certificate is issued#

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 Certificate and 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.

Files#

File Role
01-cloudflare-api-token.example.yaml template for the token Secret — never commit the real one (prefer lab.env + platform-up.sh)
02-clusterissuer-staging.yaml Let's Encrypt staging ClusterIssuer (generous quotas, untrusted cert)
03-clusterissuer-prod.yaml Let's Encrypt prod ClusterIssuer (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-gateway Gateway (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.

✅ Verify#

kubectl get clusterissuer                                        # both issuers, READY=True
kubectl -n envoy-gateway-system get certificate                  # wildcard-…-tls, READY=True
kubectl -n envoy-gateway-system describe certificate wildcard-talos-lab-example-io-tls
                                                                 # events: Order → Challenge → issued
kubectl get challenges -A                                        # empty once validated

# Which certificate does Envoy serve? (no HTTPRoute needed: we only test TLS)
echo | openssl s_client -connect 192.168.56.200:443 -servername demo.talos.lab.example.io 2>/dev/null \
  | openssl x509 -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:

curl -sS -o /dev/null -w '%{http_code} verify=%{ssl_verify_result}\n' \
  --resolve argo.talos.lab.example.io:443:192.168.56.200 https://argo.talos.lab.example.io/
# expected: 200 verify=0   (verify=0 = chain validated without -k)

🚑 Troubleshooting#

  • 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 missingCLOUDFLARE_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).

⚠️ Pitfalls#

  • 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).

📚 References#

Source: _k8s/cert-manager/README.md10 sections
_k8s/cert-manager/LISEZ-MOI.md

📜cert-manager/ — TLS wildcard automatique (ACME DNS-01 Cloudflare)

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.

🎯 À quoi ça sert#

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.

Pourquoi DNS-01, pourquoi Let's Encrypt#

  • 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é.

📋 Prérequis#

Prérequis Pourquoi Vérifier
main-gateway en place (../envoy-gateway/) c'est l'objet que cert-manager observe kubectl get gateway -n envoy-gateway-system
CRD Gateway API présentes cert-manager les découvre au démarrage (installées par le chart Envoy Gateway) kubectl get crd gateways.gateway.networking.k8s.io
Zone example.io chez Cloudflare, *.talos.lab.example.io → 192.168.56.200 en DNS-only le solveur DNS-01 écrit dans cette zone dig +short TXT _acme-challenge.talos.lab.example.io
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.

⚡ Installation#

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-manager v1.20.2, épinglé dans ../platform-up.sh (CERT_MANAGER_VERSION). Le script :

  1. 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) ;
  2. 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) ;
  3. applique 02-clusterissuer-staging.yaml et 03-clusterissuer-prod.yaml ;
  4. attend Ready=True sur le Certificate wildcard-talos-lab-example-io-tls — nom dérivé de LAB_DOMAIN (~1-2 min, 24 × 10 s).
Équivalent manuel (poser uniquement cette brique)
helm repo add jetstack https://charts.jetstack.io && helm repo update
# --version : garder celle de platform-up.sh (CERT_MANAGER_VERSION)
helm upgrade --install cert-manager jetstack/cert-manager \
  --namespace cert-manager --create-namespace \
  --version v1.20.2 \
  --set crds.enabled=true \
  --set config.apiVersion="controller.config.cert-manager.io/v1alpha1" \
  --set config.kind="ControllerConfiguration" \
  --set config.enableGatewayAPI=true
kubectl -n cert-manager rollout status deploy/cert-manager
kubectl create secret generic cloudflare-api-token -n cert-manager \
  --from-literal=api-token='<TON_TOKEN>'
kubectl apply -f _k8s/cert-manager/02-clusterissuer-staging.yaml \
              -f _k8s/cert-manager/03-clusterissuer-prod.yaml

🔧 Comment le certificat est émis#

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 Certificate et 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.

Fichiers#

Fichier Rôle
01-cloudflare-api-token.example.yaml 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 Gateway main-gateway complet (mêmes name/namespace) : le kubectl apply remplacerait 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.

✅ Vérifier#

kubectl get clusterissuer                                        # les 2 émetteurs, READY=True
kubectl -n envoy-gateway-system get certificate                  # wildcard-…-tls, READY=True
kubectl -n envoy-gateway-system describe certificate wildcard-talos-lab-example-io-tls
                                                                 # events : Order → Challenge → issued
kubectl get challenges -A                                        # vide une fois validé

# Quel certificat Envoy sert-il ? (aucune HTTPRoute nécessaire : on ne teste que le TLS)
echo | openssl s_client -connect 192.168.56.200:443 -servername demo.talos.lab.example.io 2>/dev/null \
  | openssl x509 -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é :

curl -sS -o /dev/null -w '%{http_code} verify=%{ssl_verify_result}\n' \
  --resolve argo.talos.lab.example.io:443:192.168.56.200 https://argo.talos.lab.example.io/
# attendu : 200 verify=0   (verify=0 = chaîne validée sans -k)

🚑 Dépannage#

  • 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 absentCLOUDFLARE_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).

⚠️ Pièges#

  • 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).

📚 Références#

Source : _k8s/cert-manager/LISEZ-MOI.md10 sections
_k8s/longhorn/README.md

🐮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/.

🎯 Purpose#

Ship two StorageClasses and the CSI driver behind them:

StorageClass Block replicas Default For whom
longhorn 3 yes (values.yaml) data worth protecting: ../wordpress-example/, ../vault-cluster/
longhorn-r1 1 no ../cloudnative-pg/ and ../observability/ (application-level replication, or rebuildable data)

Files in this directory:

File Purpose
longhorn-up.sh the install: checks the extensions, applies the rshared mount, chart + both StorageClasses + HTTPRoute
schematic.yaml Image Factory schematic → Talos installer with iscsi-tools + util-linux-tools
patch-longhorn.yaml Machine config patch: kubelet.extraMounts for /var/lib/longhorn as rshared
values.yaml Helm values: defaultDataPath, defaultReplicaCount: 3, persistence.defaultClass: true
longhorn-r1-storageclass.yaml Baseline StorageClass longhorn-r1 (1 block replica)
httproute.yaml HTTPS HTTPRoute longhorn.talos.lab.example.iolonghorn-frontend:80 on main-gateway

📋 Prerequisites#

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 no extraMounts at all — this is why longhorn-up.sh applies the patch itself, to the workers, before the chart.

⚡ Install#

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.

1. Installer with the extensions (Image Factory)#

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:

SCHEMATIC_ID=$(curl -sX POST --data-binary @_k8s/longhorn/schematic.yaml \
  https://factory.talos.dev/schematics -H "Content-Type: application/yaml" | jq -r .id)
echo "factory.talos.dev/installer/${SCHEMATIC_ID}:v1.13.7"

Paste the already resolved line into lab.env:

# 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_IMAGEcluster-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:
for ip in 192.168.56.101 192.168.56.102 192.168.56.103; do
  talosctl -n "$ip" patch mc --patch @_k8s/longhorn/patch-longhorn.yaml
done
Fresh cluster: inject the patch at gen config time (manual)
talosctl gen config talos-lab https://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
talosctl validate --config _out/controlplane.yaml --mode metal
# 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:

talosctl -n 192.168.56.101 upgrade \
  --image factory.talos.dev/installer/${SCHEMATIC_ID}:v1.13.7 --preserve
talosctl -n 192.168.56.101 patch mc --patch @_k8s/longhorn/patch-longhorn.yaml

3. Namespace + Pod Security — automated by longhorn-up.sh#

kubectl create namespace longhorn-system --dry-run=client -o yaml | kubectl apply -f -
kubectl label namespace longhorn-system \
  pod-security.kubernetes.io/enforce=privileged \
  pod-security.kubernetes.io/audit=privileged \
  pod-security.kubernetes.io/warn=privileged --overwrite

4. Helm chart + baseline StorageClass — automated by longhorn-up.sh#

helm repo add longhorn https://charts.longhorn.io && helm repo update
# --version: pin it; check the latest on charts.longhorn.io
helm install longhorn longhorn/longhorn \
  --namespace longhorn-system \
  --version 1.12.0 \
  -f _k8s/longhorn/values.yaml
kubectl -n longhorn-system rollout status deploy/longhorn-driver-deployer
kubectl apply -f _k8s/longhorn/longhorn-r1-storageclass.yaml

🔧 Under the hood#

Why longhorn-r1 (1 replica)#

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.

Dedicated disk (the "clean" setup, optional)#

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:

  1. 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).
  2. Talos: a UserVolumeConfig document (mounts automatically on /var/mnt/<name>), then point kubelet.extraMounts and defaultDataPath at /var/mnt/longhorn:
    apiVersion: v1alpha1
    kind: UserVolumeConfig
    name: longhorn
    provisioning:
      diskSelector:
        match: disk.transport == "sata" && !system_disk   # the 2nd disk, not /dev/sda
      grow: true
    

✅ Verify#

talosctl -n 192.168.56.101 get extensions        # iscsi-tools + util-linux-tools present
talosctl -n 192.168.56.101 services              # ext-iscsid Running
kubectl -n longhorn-system get pods              # instance-manager, manager, csi-* Running
kubectl get storageclass                         # longhorn (default) + longhorn-r1
kubectl -n longhorn-system get nodes.longhorn.io # every node "Schedulable", disk Ready

# Quick test: a PVC must bind
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: test-longhorn }
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: longhorn
  resources: { requests: { storage: 1Gi } }
EOF
kubectl get pvc test-longhorn                    # Bound
kubectl delete pvc test-longhorn

🌐 Access#

longhorn-up.sh already applied the HTTPRoute (its step [5/5]). To re-apply it alone:

kubectl apply -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:

sed 's/talos\.lab\.example\.io/talos.lab.my-domain.tld/g' \
  _k8s/longhorn/httproute.yaml | kubectl apply -f -

(see ../README.md).

Interface URL / command Auth
Longhorn UI (HTTPS via main-gateway) https://longhorn.talos.lab.example.io none
Without exposing it kubectl -n longhorn-system port-forward svc/longhorn-frontend 8080:80

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.

⚠️ Pitfalls#

  • 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:
    kubectl annotate storageclass local-path storageclass.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.

📚 References#

Source: _k8s/longhorn/README.md14 sections
_k8s/longhorn/LISEZ-MOI.md

🐮longhorn/ — stockage bloc répliqué (Longhorn 1.12) sur Talos

Fournit des PersistentVolume ré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/.

🎯 À quoi ça sert#

Poser deux StorageClass et le CSI qui va avec :

StorageClass Réplicas bloc Par défaut Pour qui
longhorn 3 oui (values.yaml) données à protéger : ../wordpress-example/, ../vault-cluster/
longhorn-r1 1 non ../cloudnative-pg/ et ../observability/ (réplication applicative ou donnée reconstructible)

Fichiers du dossier :

Fichier Rôle
longhorn-up.sh l'install : vérifie les extensions, applique le montage rshared, chart + les deux StorageClass + HTTPRoute
schematic.yaml Schematic Image Factory → installeur Talos avec iscsi-tools + util-linux-tools
patch-longhorn.yaml Patch machine config : kubelet.extraMounts /var/lib/longhorn en rshared
values.yaml Valeurs Helm : defaultDataPath, defaultReplicaCount: 3, persistence.defaultClass: true
longhorn-r1-storageclass.yaml StorageClass socle longhorn-r1 (1 réplica bloc)
httproute.yaml HTTPRoute HTTPS longhorn.talos.lab.example.iolonghorn-frontend:80 sur main-gateway

📋 Prérequis#

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 aucun extraMounts — c'est pour ça que longhorn-up.sh applique lui-même le patch aux workers, avant le chart.

⚡ Installation#

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 :

SCHEMATIC_ID=$(curl -sX POST --data-binary @_k8s/longhorn/schematic.yaml \
  https://factory.talos.dev/schematics -H "Content-Type: application/yaml" | jq -r .id)
echo "factory.talos.dev/installer/${SCHEMATIC_ID}:v1.13.7"

Colle la ligne déjà résolue dans lab.env :

# 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 :
for ip in 192.168.56.101 192.168.56.102 192.168.56.103; do
  talosctl -n "$ip" patch mc --patch @_k8s/longhorn/patch-longhorn.yaml
done
Cluster neuf : injecter le patch au gen config (manuel)
talosctl gen config talos-lab https://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
talosctl validate --config _out/controlplane.yaml --mode metal
# 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 :

talosctl -n 192.168.56.101 upgrade \
  --image factory.talos.dev/installer/${SCHEMATIC_ID}:v1.13.7 --preserve
talosctl -n 192.168.56.101 patch mc --patch @_k8s/longhorn/patch-longhorn.yaml

3. Namespace + Pod Security — automatisé par longhorn-up.sh#

kubectl create namespace longhorn-system --dry-run=client -o yaml | kubectl apply -f -
kubectl label namespace longhorn-system \
  pod-security.kubernetes.io/enforce=privileged \
  pod-security.kubernetes.io/audit=privileged \
  pod-security.kubernetes.io/warn=privileged --overwrite

4. Chart Helm + StorageClass socle — automatisé par longhorn-up.sh#

helm repo add longhorn https://charts.longhorn.io && helm repo update
# --version : épingle ; vérifier la dernière sur charts.longhorn.io
helm install longhorn longhorn/longhorn \
  --namespace longhorn-system \
  --version 1.12.0 \
  -f _k8s/longhorn/values.yaml
kubectl -n longhorn-system rollout status deploy/longhorn-driver-deployer
kubectl apply -f _k8s/longhorn/longhorn-r1-storageclass.yaml

🔧 Sous le capot#

Pourquoi longhorn-r1 (1 réplica)#

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.

Disque dédié (setup « propre », optionnel)#

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 :

  1. 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)).
  2. Talos : un document UserVolumeConfig (monte automatiquement en /var/mnt/<name>), puis adapter kubelet.extraMounts et defaultDataPath sur /var/mnt/longhorn :
    apiVersion: v1alpha1
    kind: UserVolumeConfig
    name: longhorn
    provisioning:
      diskSelector:
        match: disk.transport == "sata" && !system_disk   # le 2e disque, pas /dev/sda
      grow: true
    

✅ Vérifier#

talosctl -n 192.168.56.101 get extensions        # iscsi-tools + util-linux-tools présents
talosctl -n 192.168.56.101 services              # ext-iscsid en Running
kubectl -n longhorn-system get pods              # instance-manager, manager, csi-* Running
kubectl get storageclass                         # longhorn (default) + longhorn-r1
kubectl -n longhorn-system get nodes.longhorn.io # chaque node "Schedulable", disque Ready

# Test rapide : un PVC doit se lier
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: test-longhorn }
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: longhorn
  resources: { requests: { storage: 1Gi } }
EOF
kubectl get pvc test-longhorn                    # Bound
kubectl delete pvc test-longhorn

🌐 Accès#

longhorn-up.sh a déjà appliqué l'HTTPRoute (son étape [5/5]). Pour la réappliquer seule :

kubectl apply -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 :

sed 's/talos\.lab\.example\.io/talos.lab.mon-domaine.tld/g' \
  _k8s/longhorn/httproute.yaml | kubectl apply -f -

(cf. ../LISEZ-MOI.md).

Interface URL / commande Auth
UI Longhorn (HTTPS via main-gateway) https://longhorn.talos.lab.example.io aucune
Sans exposition kubectl -n longhorn-system port-forward svc/longhorn-frontend 8080:80

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.

⚠️ Pièges#

  • 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 :
    kubectl annotate storageclass local-path storageclass.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.

📚 Références#

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-provisioner v0.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.

🎯 Purpose#

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

📋 Prerequisites#

Prerequisite Why Verify
Talos cluster with a working CNI (talos/cluster-up.sh) the provisioner is a plain Deployment kubectl get nodes
≥ 1 schedulable worker each PV lands on the node of the first consuming pod (WaitForFirstConsumer) kubectl get nodes -l '!node-role.kubernetes.io/control-plane'
Free space on the worker's /var partition PVs are hostPath directories, not sized volumes kubectl describe node <w> | grep -A3 Allocatable

⚡ Install#

./_k8s/local-path-storage/local-path-up.sh

Idempotent (kubectl apply + rollout status). Manual equivalent: kubectl apply -f _k8s/local-path-storage/local-path-storage.yaml.

🔧 Three deviations from the upstream manifest#

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:

# Change Why
1 Path /opt/local-path-provisioner/var/local-path-provisioner On Talos, / and /etc are read-only; only the /var partition is writable. A helper pod trying mkdir /opt/... fails (read-only file system).
2 Namespace local-path-storage in PodSecurity privileged The helper pods (creating/deleting the PV directories) mount hostPath, which the Talos cluster default baseline rejects.
3 StorageClass local-path marked default (is-default-class) 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.

Tuning#

Everything is tuned in local-path-storage.yaml:

  • Storage pathConfigMap local-path-config, key config.json:
    { "nodePathMap":[ { "node":"DEFAULT_PATH_FOR_NON_LISTED_NODES",
                        "paths":["/var/local-path-provisioner"] } ] }
    
    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.

✅ Verify#

kubectl get storageclass                      # local-path (default)
kubectl -n local-path-storage get pods        # local-path-provisioner 1/1 Running

# Test: a PVC only binds once a pod shows up (WaitForFirstConsumer)
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: lp-test, namespace: default }
spec:
  accessModes: [ReadWriteOnce]
  resources: { requests: { storage: 128Mi } }
EOF
kubectl -n default run lp-test --image=busybox:1.36 --restart=Never \
  --overrides='{"spec":{"volumes":[{"name":"d","persistentVolumeClaim":{"claimName":"lp-test"}}],"containers":[{"name":"c","image":"busybox:1.36","command":["sh","-c","echo ok>/data/x && cat /data/x && sleep 3"],"volumeMounts":[{"name":"d","mountPath":"/data"}]}]}}'
kubectl -n default get pvc lp-test            # STATUS Bound
kubectl -n default delete pod lp-test; kubectl -n default delete pvc lp-test

⚠️ Pitfalls#

  • 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:
    kubectl get nodes -o custom-columns=NAME:.metadata.name,EPH:.status.allocatable.ephemeral-storage
    kubectl describe node <worker> | grep -i pressure
    
  • 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:
    kubectl annotate storageclass local-path storageclass.kubernetes.io/is-default-class-
    
  • 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.

🧹 Uninstall#

kubectl delete -f _k8s/local-path-storage/local-path-storage.yaml

Removes the StorageClass and the provisioner (read the last pitfall before running it).

📚 References#

Source: _k8s/local-path-storage/README.md9 sections
_k8s/local-path-storage/LISEZ-MOI.md

📁local-path-storage/ — stockage local dynamique (sans Longhorn), adapté Talos

Déploie Rancher local-path-provisioner v0.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é.

🎯 À quoi ça sert#

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

📋 Prérequis#

Prérequis Pourquoi Vérifier
Cluster Talos avec CNI opérationnel (talos/cluster-up.sh) le provisioner est un Deployment normal kubectl get nodes
≥ 1 worker schedulable chaque PV atterrit sur le node du premier pod consommateur (WaitForFirstConsumer) kubectl get nodes -l '!node-role.kubernetes.io/control-plane'
Place sur la partition /var du worker les PV sont des dossiers hostPath, pas des volumes taillés kubectl describe node <w> | grep -A3 Allocatable

⚡ Installation#

./_k8s/local-path-storage/local-path-up.sh

Idempotent (kubectl apply + rollout status). Équivalent manuel : kubectl apply -f _k8s/local-path-storage/local-path-storage.yaml.

🔧 Trois écarts vs le manifeste upstream#

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 :

# Modification Pourquoi
1 Chemin /opt/local-path-provisioner/var/local-path-provisioner 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.

Réglages#

Tout se règle dans local-path-storage.yaml :

  • Chemin de stockageConfigMap local-path-config, clé config.json :
    { "nodePathMap":[ { "node":"DEFAULT_PATH_FOR_NON_LISTED_NODES",
                        "paths":["/var/local-path-provisioner"] } ] }
    
    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.

✅ Vérifier#

kubectl get storageclass                      # local-path (default)
kubectl -n local-path-storage get pods        # local-path-provisioner 1/1 Running

# Test : un PVC ne se lie qu'à l'arrivée d'un pod (WaitForFirstConsumer)
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: lp-test, namespace: default }
spec:
  accessModes: [ReadWriteOnce]
  resources: { requests: { storage: 128Mi } }
EOF
kubectl -n default run lp-test --image=busybox:1.36 --restart=Never \
  --overrides='{"spec":{"volumes":[{"name":"d","persistentVolumeClaim":{"claimName":"lp-test"}}],"containers":[{"name":"c","image":"busybox:1.36","command":["sh","-c","echo ok>/data/x && cat /data/x && sleep 3"],"volumeMounts":[{"name":"d","mountPath":"/data"}]}]}}'
kubectl -n default get pvc lp-test            # STATUS Bound
kubectl -n default delete pod lp-test; kubectl -n default delete pvc lp-test

⚠️ Pièges#

  • 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 :
    kubectl get nodes -o custom-columns=NAME:.metadata.name,EPH:.status.allocatable.ephemeral-storage
    kubectl describe node <worker> | grep -i pressure
    
  • 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 :
    kubectl annotate storageclass local-path storageclass.kubernetes.io/is-default-class-
    
  • 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.

🧹 Désinstaller#

kubectl delete -f _k8s/local-path-storage/local-path-storage.yaml

Supprime la StorageClass et le provisioner (voir le dernier piège avant de lancer).

📚 Références#

Source : _k8s/local-path-storage/LISEZ-MOI.md9 sections
_k8s/minio-s3/README.md

🪣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/.

🎯 Purpose#

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.

Standalone (here) or distributed (cluster/)?#

Standalone (here) Cluster (cluster/)
Workload Deployment, 1 replica StatefulSet, 4 pods (podManagementPolicy: Parallel)
Drives 1 local-path PVC, 10 Gi 4 local-path PVCs, 10 Gi (1/pod, 1/worker)
Erasure coding ❌ none EC:2
Resilience none: the node dies ⇒ data gone tolerates ~2 nodes/drives down
Workers required 1 4 (strict anti-affinity)
Namespace minio-s3 minio-cluster (both coexist)
Hostnames minio / minio-console minio-cluster / minio-cluster-console

ℹ️ 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").

📋 Prerequisites#

Prerequisite Why Verify
StorageClass local-path (../local-path-storage/) the 10 Gi PVC behind /data; minio-up.sh bails out without it kubectl get storageclass local-path
main-gateway + https listener (../envoy-gateway/) carries both HTTPRoutes kubectl get gateway -n envoy-gateway-system
Wildcard cert *.talos.lab.example.io (../cert-manager/) TLS for both hostnames kubectl -n envoy-gateway-system get certificate
DNS minio + minio-console192.168.56.200 to reach the Envoy VIP getent hosts minio.talos.lab.example.io
openssl on the host generates the default root password openssl version

⚡ Install#

./_k8s/minio-s3/minio-up.sh
# Tunable credentials: MINIO_ROOT_USER (default "admin") / MINIO_ROOT_PASSWORD (generated)
MINIO_ROOT_PASSWORD='MyLabPass' ./_k8s/minio-s3/minio-up.sh

Image pinned in minio-s3.yaml: docker.io/pgsty/minio:RELEASE.2026-06-18T00-00-00Z.

🔧 What the script does#

  1. Checks kubectl, the apiserver and the presence of the local-path StorageClass.
  2. 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.
  3. Applies minio-s3.yaml: 10 Gi PVC, Deployment (strategy: Recreate, since the RWO volume cannot take two pods), ClusterIP Service, two HTTPRoutes.
  4. Waits for the rollout (180 s) then prints the URLs and the root credentials in clear text on stdout (see Pitfalls).

Why the Pigsty fork (pgsty/minio)#

Neither the "official" image nor Bitnami:

  • Bitnami (bitnami/minio) has relied since August 2025 on frozen images (bitnamilegacy/*, no longer updated).
  • Upstream minio/minio gutted 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.

✅ Verify#

kubectl -n minio-s3 get pods,pvc,svc,httproute
curl -sk -o /dev/null -w '%{http_code}\n' --resolve minio.talos.lab.example.io:443:192.168.56.200 \
  https://minio.talos.lab.example.io/minio/health/ready      # 200

🌐 Access#

What How
Admin console https://minio-console.talos.lab.example.io
S3 API https://minio.talos.lab.example.io (path-style, any region: us-east-1)
Root user kubectl -n minio-s3 get secret minio-creds -o jsonpath='{.data.root-user}' | base64 -d; echo
Root password kubectl -n minio-s3 get secret minio-creds -o jsonpath='{.data.root-password}' | base64 -d; echo

The wildcard cert is issued by Let's Encrypt staging → a TLS warning to accept in the browser, and --insecure for mc (see ../cert-manager/).

mc alias set lab https://minio.talos.lab.example.io <user> <pass> --insecure
mc mb lab/my-bucket --insecure                        # create a bucket
mc admin user add lab bob <password> --insecure       # manage users
mc ls lab --insecure

⚠️ Pitfalls#

  • 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 direct kubectl apply -f minio-s3.yaml keeps the example domain and breaks the login redirects. See ../README.md.

📚 References#

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

🎯 À quoi ça sert#

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.

Standalone (ici) ou distribué (cluster/) ?#

Standalone (ici) Cluster (cluster/)
Workload Deployment, 1 replica StatefulSet, 4 pods (podManagementPolicy: Parallel)
Drives 1 PVC local-path 10 Gi 4 PVC local-path 10 Gi (1/pod, 1/worker)
Erasure coding ❌ aucun EC:2
Résilience nulle : le node meurt ⇒ données perdues tolère ~2 nœuds/drives down
Workers requis 1 4 (anti-affinité stricte)
Namespace minio-s3 minio-cluster (les deux coexistent)
Hostnames minio / minio-console minio-cluster / minio-cluster-console

ℹ️ 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 »).

📋 Prérequis#

Prérequis Pourquoi Vérifier
StorageClass local-path (../local-path-storage/) le PVC 10 Gi de /data ; minio-up.sh s'arrête sans elle kubectl get storageclass local-path
main-gateway + écouteur https (../envoy-gateway/) porte les deux HTTPRoute kubectl get gateway -n envoy-gateway-system
Cert wildcard *.talos.lab.example.io (../cert-manager/) TLS des deux hostnames kubectl -n envoy-gateway-system get certificate
DNS minio + minio-console192.168.56.200 atteindre le VIP Envoy getent hosts minio.talos.lab.example.io
openssl sur l'hôte génère le mot de passe root par défaut openssl version

⚡ Installation#

./_k8s/minio-s3/minio-up.sh
# Identifiants réglables : MINIO_ROOT_USER (défaut « admin ») / MINIO_ROOT_PASSWORD (généré)
MINIO_ROOT_PASSWORD='MonPassLab' ./_k8s/minio-s3/minio-up.sh

Image épinglée dans minio-s3.yaml : docker.io/pgsty/minio:RELEASE.2026-06-18T00-00-00Z.

🔧 Ce que fait le script#

  1. Vérifie kubectl, l'apiserver et la présence de la StorageClass local-path.
  2. 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.
  3. Applique minio-s3.yaml : PVC 10 Gi, Deployment (strategy: Recreate, car le volume RWO n'accepte pas deux pods), Service ClusterIP, deux HTTPRoute.
  4. Attend le rollout (180 s) puis affiche les URL et les identifiants root en clair sur stdout (cf. Pièges).

Pourquoi le fork Pigsty (pgsty/minio)#

Ni l'image « officielle », ni Bitnami :

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

✅ Vérifier#

kubectl -n minio-s3 get pods,pvc,svc,httproute
curl -sk -o /dev/null -w '%{http_code}\n' --resolve minio.talos.lab.example.io:443:192.168.56.200 \
  https://minio.talos.lab.example.io/minio/health/ready      # 200

🌐 Accès#

Quoi Comment
Console admin https://minio-console.talos.lab.example.io
API S3 https://minio.talos.lab.example.io (path-style, region quelconque : us-east-1)
Utilisateur root kubectl -n minio-s3 get secret minio-creds -o jsonpath='{.data.root-user}' | base64 -d; echo
Mot de passe root kubectl -n minio-s3 get secret minio-creds -o jsonpath='{.data.root-password}' | base64 -d; echo

Le cert wildcard est émis par Let's Encrypt staging → avertissement TLS à accepter dans le navigateur, et --insecure pour mc (cf. ../cert-manager/).

mc alias set lab https://minio.talos.lab.example.io <user> <pass> --insecure
mc mb lab/mon-bucket --insecure                       # créer un bucket
mc admin user add lab bob <mot-de-passe> --insecure   # gérer les users
mc ls lab --insecure

⚠️ Pièges#

  • 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.yaml direct garde le domaine d'exemple et casse les redirections de login. Cf. ../LISEZ-MOI.md.

📚 Références#

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.

🎯 Purpose#

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

Service URL Port
S3 API https://minio-cluster.talos.lab.example.io 9000
Admin console https://minio-cluster-console.talos.lab.example.io 9001

Inside the cluster: http://minio.minio-cluster.svc.cluster.local:9000.

📋 Prerequisites#

Prerequisite Why Verify
≥ 4 Ready workers 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'
StorageClass local-path (../../local-path-storage/) 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-console192.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=300s fails 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.

⚡ Install#

./_k8s/minio-s3/cluster/minio-cluster-up.sh
# Tunable credentials: MINIO_ROOT_USER (default "admin") / MINIO_ROOT_PASSWORD (generated)

Image pinned in minio-cluster.yaml: docker.io/pgsty/minio:RELEASE.2026-06-18T00-00-00Z (Pigsty fork — recent + admin console, see ../README.md).

🔧 What the script does#

  1. Checks kubectl, the apiserver, the local-path StorageClass, then counts the Ready workers and warns if there are fewer than 4 (without blocking).
  2. Creates the minio-cluster namespace and the minio-creds Secret — not overwritten if it exists.
  3. Applies minio-cluster.yaml (headless Service, StatefulSet, ClusterIP Service, 2 HTTPRoutes).
  4. Waits for rollout status statefulset/minio --timeout=300s, then prints the URLs and the root credentials in clear text (see Pitfalls).

Why 4 nodes (and not 3)#

MinIO requires a minimum of 4 drives per erasure set, spread evenly across the nodes. With 1 drive per pod (the clean K8s pattern on local-path):

  • 3 nodes × 1 drive = 3 drives → below the minimum and not even → rejected.
  • 4 nodes × 1 drive = 4 drives → 1 erasure set of 4, EC:2 → the natural minimum.
  • 3 nodes only becomes possible again with ≥ 2 drives/node (3 × 2 = 6 drives = 1 set of 6), i.e. 2 PVCs per pod: more complex, not chosen here.

ℹ️ EC:2 over 4 drives = 2 data + 2 parity. You can lose up to 2 drives/nodes and still read; writing requires a quorum (≥ half the drives + 1).

Deployed topology#

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.
  • hostname anti-affinity (required…): 1 pod/worker → erasure spread over 4 distinct nodes.
  • startupProbe failureThreshold: 30 × 5 s: up to 150 s to form the quorum at boot.

✅ Verify#

kubectl -n minio-cluster get pods -o wide            # minio-0..3, 1/1, on 4 distinct workers
mc alias set clu https://minio-cluster.talos.lab.example.io <user> <pass> --insecure
mc admin info clu                                    # "4 drives online, 0 offline", EC:2

🌐 Access#

What How
Admin console https://minio-cluster-console.talos.lab.example.io
S3 API https://minio-cluster.talos.lab.example.io (path-style)
Root user kubectl -n minio-cluster get secret minio-creds -o jsonpath='{.data.root-user}' | base64 -d; echo
Root password kubectl -n minio-cluster get secret minio-creds -o jsonpath='{.data.root-password}' | base64 -d; echo

Let's Encrypt staging cert → --insecure for mc, and a warning to accept in the browser.

🧪 Scenarios#

Test the resilience — delete a pod: the cluster stays readable/writable.

kubectl -n minio-cluster delete pod minio-2
mc admin info clu       # 3/4 online, still operational; the pod comes back and resyncs

Migrate from the standalone — both MinIOs coexist (distinct namespaces and hostnames):

mc alias set std https://minio.talos.lab.example.io <user> <pass> --insecure
mc alias set clu https://minio-cluster.talos.lab.example.io <user> <pass> --insecure
mc mb clu/pg-backups clu/cnpg-backups
mc mirror --preserve std/pg-backups clu/pg-backups
mc mirror --preserve std/cnpg-backups clu/cnpg-backups

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.

⚠️ Pitfalls#

  • 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:
    kubectl get nodes -o custom-columns=NAME:.metadata.name,EPH:.status.allocatable.ephemeral-storage
    kubectl describe node <worker> | grep -i pressure
    
  • 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.

📚 References#

  • ../README.md — standalone MinIO, and the details of why the pgsty/minio fork.
  • ../../local-path-storage/ — the StorageClass consumed by the 4 drives.
  • ../../cloudnative-pg/ — the PostgreSQL backups that target this cluster.
  • MinIO documentation (Kubernetes) — distributed deployment, erasure coding and quorum.
Source: _k8s/minio-s3/cluster/README.md11 sections
_k8s/minio-s3/cluster/LISEZ-MOI.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.

🎯 À quoi ça sert#

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

Service URL Port
API S3 https://minio-cluster.talos.lab.example.io 9000
Console admin https://minio-cluster-console.talos.lab.example.io 9001

En interne : http://minio.minio-cluster.svc.cluster.local:9000.

📋 Prérequis#

Prérequis Pourquoi Vérifier
≥ 4 workers Ready 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'
StorageClass local-path (../../local-path-storage/) 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-console192.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.

⚡ Installation#

./_k8s/minio-s3/cluster/minio-cluster-up.sh
# Identifiants réglables : MINIO_ROOT_USER (défaut « admin ») / MINIO_ROOT_PASSWORD (généré)

Image épinglée dans minio-cluster.yaml : docker.io/pgsty/minio:RELEASE.2026-06-18T00-00-00Z (fork Pigsty — récent + console d'admin, cf. ../LISEZ-MOI.md).

🔧 Ce que fait le script#

  1. Vérifie kubectl, l'apiserver, la StorageClass local-path, puis compte les workers Ready et avertit s'il y en a moins de 4 (sans bloquer).
  2. Crée le namespace minio-cluster et le Secret minio-credsnon écrasé s'il existe.
  3. Applique minio-cluster.yaml (Service headless, StatefulSet, Service ClusterIP, 2 HTTPRoute).
  4. Attend rollout status statefulset/minio --timeout=300s, puis affiche les URL et les identifiants root en clair (cf. Pièges).

Pourquoi 4 nœuds (et pas 3)#

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

Topologie déployée#

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.
  • anti-affinité hostname (required…) : 1 pod/worker → erasure réparti sur 4 nœuds distincts.
  • startupProbe failureThreshold: 30 × 5 s : jusqu'à 150 s pour former le quorum au boot.

✅ Vérifier#

kubectl -n minio-cluster get pods -o wide            # minio-0..3, 1/1, sur 4 workers distincts
mc alias set clu https://minio-cluster.talos.lab.example.io <user> <pass> --insecure
mc admin info clu                                    # « 4 drives online, 0 offline », EC:2

🌐 Accès#

Quoi Comment
Console admin https://minio-cluster-console.talos.lab.example.io
API S3 https://minio-cluster.talos.lab.example.io (path-style)
Utilisateur root kubectl -n minio-cluster get secret minio-creds -o jsonpath='{.data.root-user}' | base64 -d; echo
Mot de passe root kubectl -n minio-cluster get secret minio-creds -o jsonpath='{.data.root-password}' | base64 -d; echo

Cert Let's Encrypt staging--insecure pour mc, avertissement à accepter au navigateur.

🧪 Scénarios#

Tester la résilience — supprimer un pod : le cluster reste lisible/écrivable.

kubectl -n minio-cluster delete pod minio-2
mc admin info clu       # 3/4 online, toujours opérationnel ; le pod revient et se resynchronise

Migrer depuis le standalone — les deux MinIO coexistent (namespaces et hostnames distincts) :

mc alias set std https://minio.talos.lab.example.io <user> <pass> --insecure
mc alias set clu https://minio-cluster.talos.lab.example.io <user> <pass> --insecure
mc mb clu/pg-backups clu/cnpg-backups
mc mirror --preserve std/pg-backups clu/pg-backups
mc mirror --preserve std/cnpg-backups clu/cnpg-backups

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.

⚠️ Pièges#

  • 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 :
    kubectl get nodes -o custom-columns=NAME:.metadata.name,EPH:.status.allocatable.ephemeral-storage
    kubectl describe node <worker> | grep -i pressure
    
  • 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.

📚 Références#

  • ../LISEZ-MOI.md — MinIO standalone, et le détail du pourquoi le fork pgsty/minio.
  • ../../local-path-storage/ — la StorageClass consommée par les 4 drives.
  • ../../cloudnative-pg/ — les sauvegardes PostgreSQL qui visent ce cluster.
  • Documentation MinIO (Kubernetes) — déploiement distribué, erasure coding et quorum.
Source : _k8s/minio-s3/cluster/LISEZ-MOI.md11 sections
_k8s/vault-cluster/README.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).

🎯 Purpose#

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 values.yaml (injector.enabled)
Audit device disabled (auditStorage) — one PVC less values.yaml

📋 Prerequisites#

Prerequisite Why Verify
Longhorn (SC longhorn) backs the 3 Raft PVCs kubectl get sc
platform-up.sh (main-gateway + https listener) exposes the UI/API over HTTPS kubectl get gateway -n envoy-gateway-system
Wildcard cert *.talos.lab.example.io the HTTPRoute has no dedicated Certificate kubectl -n envoy-gateway-system get certificate
DNS vault.talos.lab.example.io → 192.168.56.200 reach the UI from the host getent hosts vault.talos.lab.example.io
jq on the host slice up the JSON output of operator init jq --version

See ../longhorn/, ../envoy-gateway/, ../cert-manager/.

⚡ Install#

./_k8s/vault-cluster/vault-up.sh

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.

The chart alone, by hand
helm repo add hashicorp https://helm.releases.hashicorp.com && helm repo update
helm upgrade --install vault hashicorp/vault \
  --namespace vault --create-namespace \
  --version 0.34.0 \
  --values _k8s/vault-cluster/values.yaml

Chart 0.34.0 → image hashicorp/vault:2.0.3 (pinned versions, see the header of values.yaml).

The 3 pods start, then stay 0/1 Running and SEALED: that is expected, the readiness probe fails as long as Vault is neither initialized nor unsealed.

🔐 Initialize + unseal#

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 -n vault exec vault-0 -- vault operator init \
  -key-shares=5 -key-threshold=3 -format=json > vault-init.json

# Unseal vault-0 (3 distinct keys): it becomes the leader
for i in 0 1 2; do
  kubectl -n vault exec vault-0 -- vault operator unseal \
    "$(jq -r ".unseal_keys_b64[$i]" vault-init.json)"
done

# vault-1 and vault-2 join the Raft (retry_join), then unseal in turn
for p in vault-1 vault-2; do for i in 0 1 2; do
  kubectl -n vault exec $p -- vault operator unseal \
    "$(jq -r ".unseal_keys_b64[$i]" vault-init.json)"
done; done

# Root token:
jq -r .root_token vault-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:

kubectl apply -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:

sed 's/talos\.lab\.example\.io/talos.lab.my-domain.tld/g' \
  _k8s/vault-cluster/httproute.yaml | kubectl apply -f -

(see ../README.md).

🔧 Wiring up the VSO#

The VSO (../vault-secret-operator/) is already wired to http://vault.vault.svc.cluster.local:8200 through the default VaultConnection 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.

✅ Verify#

kubectl -n vault get pods                                        # 1/1 Running after unseal
kubectl -n vault exec vault-0 -- vault status                      # Sealed=false, HA Mode
kubectl -n vault exec vault-0 -- vault operator raft list-peers   # 3 voters
kubectl -n vault get endpoints vault-active                       # 1 endpoint = the leader
curl -sS -o /dev/null -w '%{http_code}\n' \
  --resolve vault.talos.lab.example.io:443:192.168.56.200 \
  https://vault.talos.lab.example.io/ui/                              # 200

🌐 Access#

What Value / command
UI (public HTTPS) https://vault.talos.lab.example.io — login method "Token"
Root token jq -r .root_token vault-init.json
API from inside the cluster http://vault.vault.svc.cluster.local:8200 (what the VSO consumes)
API from the host, without DNS kubectl -n vault port-forward svc/vault-active 8200:8200http://127.0.0.1:8200

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.

⚠️ Pitfalls#

  • 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
    vault status                     # 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.

🚑 Troubleshooting#

Symptom Cause Action
Pods stuck 0/1 Running Vault sealed (readiness fails) unseal (see §🔐)
Route 503 / vault-active with no endpoint no leader elected → Vault sealed unseal the 3 pods
A pod sealed after a reboot normal behavior, no auto-unseal vault operator unseal ×3 keys
A Raft peer missing retry_join failing (vault-internal service) vault operator raft list-peers, pod logs
PVC Pending Longhorn cannot place 3 replicas see the first pitfall (longhorn-r1)

📚 References#

Source: _k8s/vault-cluster/README.md10 sections
_k8s/vault-cluster/LISEZ-MOI.md

🔒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).

🎯 À quoi ça sert#

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 values.yaml (injector.enabled)
Audit device désactivé (auditStorage) — un PVC de moins values.yaml

📋 Prérequis#

Prérequis Pourquoi Vérifier
Longhorn (SC longhorn) porte les 3 PVC Raft kubectl get sc
platform-up.sh (main-gateway + écouteur https) expose l'UI/API en HTTPS kubectl get gateway -n envoy-gateway-system
Cert wildcard *.talos.lab.example.io le HTTPRoute n'a pas de Certificate dédié kubectl -n envoy-gateway-system get certificate
DNS vault.talos.lab.example.io → 192.168.56.200 joindre l'UI depuis l'hôte getent hosts vault.talos.lab.example.io
jq sur l'hôte découper la sortie JSON de operator init jq --version

Voir ../longhorn/, ../envoy-gateway/, ../cert-manager/.

⚡ Installation#

./_k8s/vault-cluster/vault-up.sh

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.

Le chart seul, à la main
helm repo add hashicorp https://helm.releases.hashicorp.com && helm repo update
helm upgrade --install vault hashicorp/vault \
  --namespace vault --create-namespace \
  --version 0.34.0 \
  --values _k8s/vault-cluster/values.yaml

Chart 0.34.0 → image hashicorp/vault:2.0.3 (versions épinglées, cf. l'en-tête de values.yaml).

Les 3 pods démarrent puis restent 0/1 Running et SCELLÉS : normal, la readiness probe échoue tant que Vault n'est ni initialisé ni descellé.

🔐 Initialiser + desceller#

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 -n vault exec vault-0 -- vault operator init \
  -key-shares=5 -key-threshold=3 -format=json > vault-init.json

# Descelle vault-0 (3 clés distinctes) : il devient leader
for i in 0 1 2; do
  kubectl -n vault exec vault-0 -- vault operator unseal \
    "$(jq -r ".unseal_keys_b64[$i]" vault-init.json)"
done

# vault-1 et vault-2 rejoignent le Raft (retry_join) puis se descellent à leur tour
for p in vault-1 vault-2; do for i in 0 1 2; do
  kubectl -n vault exec $p -- vault operator unseal \
    "$(jq -r ".unseal_keys_b64[$i]" vault-init.json)"
done; done

# Root token :
jq -r .root_token vault-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 :

kubectl apply -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 :

sed 's/talos\.lab\.example\.io/talos.lab.mon-domaine.tld/g' \
  _k8s/vault-cluster/httproute.yaml | kubectl apply -f -

(cf. ../LISEZ-MOI.md).

🔧 Brancher le VSO#

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.

✅ Vérifier#

kubectl -n vault get pods                                        # 1/1 Running après unseal
kubectl -n vault exec vault-0 -- vault status                      # Sealed=false, HA Mode
kubectl -n vault exec vault-0 -- vault operator raft list-peers   # 3 voters
kubectl -n vault get endpoints vault-active                       # 1 endpoint = le leader
curl -sS -o /dev/null -w '%{http_code}\n' \
  --resolve vault.talos.lab.example.io:443:192.168.56.200 \
  https://vault.talos.lab.example.io/ui/                              # 200

🌐 Accès#

Quoi Valeur / commande
UI (HTTPS public) https://vault.talos.lab.example.io — méthode de login « Token »
Root token jq -r .root_token vault-init.json
API depuis le cluster http://vault.vault.svc.cluster.local:8200 (ce que consomme le VSO)
API depuis l'hôte, sans DNS kubectl -n vault port-forward svc/vault-active 8200:8200http://127.0.0.1:8200

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.

⚠️ Pièges#

  • 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
    vault status                     # 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.

🚑 Dépannage#

Symptôme Cause Geste
Pods 0/1 Running en boucle Vault scellé (readiness KO) desceller (cf. §🔐)
Route 503 / vault-active sans endpoint aucun leader élu → Vault scellé desceller les 3 pods
Un pod scellé après reboot comportement normal, pas d'auto-unseal vault operator unseal ×3 clés
Un peer Raft manquant retry_join KO (service vault-internal) vault operator raft list-peers, logs du pod
PVC Pending Longhorn ne peut pas placer 3 réplicas cf. le premier piège (longhorn-r1)

📚 Références#

Source : _k8s/vault-cluster/LISEZ-MOI.md10 sections
_k8s/vault-secret-operator/README.md

🔑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/.

🎯 Purpose#

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.

A mirrored contract#

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.

┌────────────────────── Kubernetes (k8s/ directory) ──────────────────────┐
│  ServiceAccount  ──(projected JWT token, audience "vault")──┐            │
│        ▲                                                    ▼            │
│  app Deployment        VaultAuth ── VaultConnection ── VSO (operator)    │
│        ▲  envFrom            │                              │            │
│   K8s Secret ◄── VaultStaticSecret / VaultDynamicSecret / VaultPKISecret │
└──────────────────────────────────────────┬──────────────────────────────┘
                                            │ kubernetes login + read
┌───────────────────────────────────────── ▼ ── Vault (vault/ directory) ─┐
│  auth/kubernetes  ──(TokenReview validates the JWT)──►  role  ──► policy │
│                                                                    │     │
│  kv-v2 (static) · database (dynamic) · pki (certs) · transit (cache) ◄───┘│
└───────────────────────────────────────────────────────────────────────┘

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.

📋 Prerequisites#

Prerequisite Why Verify
Vault server unsealed everything starts there vault statusSealed false
Vault address reachable from the cluster the default VaultConnection targets http://vault.vault.svc.cluster.local:8200 kubectl -n vault get svc vault
K8s API reachable by Vault TokenReview validation. In-cluster: https://kubernetes.default.svc. External Vault: the VIP https://192.168.56.5:6443 (see talos/patch-cp.yaml) vault read auth/kubernetes/config
helm + kubectl install the operator, apply the CRs helm version
vault CLI on the host run the scripts in vault/ vault version
Install the vault CLI (Ubuntu, HashiCorp repo)
wget -qO- https://apt.releases.hashicorp.com/gpg \
  | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com noble main" \
  | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update && sudo apt-get install -y vault

The noble distribution ships a generic binary, which also works on a more recent Ubuntu.

⚡ Install#

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.md
export VAULT_ADDR=https://vault.talos.lab.example.io
export VAULT_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)
helm repo add hashicorp https://helm.releases.hashicorp.com && helm repo update
helm upgrade --install vault-secrets-operator hashicorp/vault-secrets-operator \
  --namespace vault-secrets-operator --create-namespace \
  --version 1.5.0 \
  -f _k8s/vault-secret-operator/values.yaml
kubectl -n vault-secrets-operator rollout status deploy/vault-secrets-operator-controller-manager

# 3. The CRs on the cluster side                                    -> see k8s/README.md
kubectl apply -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.

🔧 What values.yaml settles#

Setting Value Effect
defaultVaultConnection.enabled true creates one VaultConnection 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.

✅ Verify#

kubectl -n vault-secrets-operator get pods
kubectl -n vault-secrets-operator logs deploy/vault-secrets-operator-controller-manager -f
kubectl get vaultconnection -A                  # the "default" created by values.yaml
kubectl get vaultauth,vaultstaticsecret,vaultdynamicsecret,vaultpkisecret -A

The per-scenario checks live in k8s/README.md, the server-side ones in vault/README.md.

🧪 Scenarios#

1. Lab KV secret → env vars → automatic restart (nginx-test-vault)#

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
kubectl apply -f _k8s/vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml

# Rotation, live: change the value in Vault…
vault kv put talos-lab/nginx-test-vault/config \
  APP_GREETING="Bonjour depuis Vault" APP_COLOR=green APP_SECRET_TOKEN=v2
# …VSO resyncs (refreshAfter 30s) -> Secret updated -> rolloutRestartTargets restarts the Deployment
kubectl -n nginx-test-vault rollout status deploy/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 -n cnpg-demo get cluster pg-demo    # "Cluster in healthy state", 3/3 instances
# a. Superuser access (Vault connects as admin "postgres" to rotate the password)
kubectl -n cnpg-demo patch cluster pg-demo --type=merge \
  -p '{"spec":{"enableSuperuserAccess":true}}'

# b. Database "vault" + user "vault-rotate", created once in PG
PRIMARY=$(kubectl -n cnpg-demo get pods \
  -l cnpg.io/cluster=pg-demo,cnpg.io/instanceRole=primary -o jsonpath='{.items[0].metadata.name}')
# bootstrap password: Vault replaces it right away
kubectl -n cnpg-demo exec "$PRIMARY" -c postgres -- psql -c \
  "CREATE ROLE \"vault-rotate\" WITH LOGIN PASSWORD 'bootstrap-temp-pw';"
kubectl -n cnpg-demo exec "$PRIMARY" -c postgres -- psql -c \
  "CREATE DATABASE vault OWNER \"vault-rotate\";"

Bringing it up:

export VAULT_ADDR=http://127.0.0.1:8200   # kubectl -n vault port-forward svc/vault-active 8200:8200
export VAULT_TOKEN=<root-token>
# ROTATION_PERIOD is tunable (default 3h; 2m to watch the loop live)
./_k8s/vault-secret-operator/vault/pg-dynamic-rotate.sh
kubectl apply -f _k8s/vault-secret-operator/k8s/pg-dynamic-rotate/pg-dynamic-rotate.yaml

Watching the rotation → the restart:

vault read database/static-creds/vault-rotate       # fixed username, password + ttl keep moving
vault write -f database/rotate-role/vault-rotate    # force an immediate rotation
kubectl -n pg-rotate-demo get deploy pg-rotate-demo -o jsonpath='{.metadata.generation}'; echo
kubectl -n pg-rotate-demo get pods -l app=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.

🛡️ The CRDs and the good practices applied#

CRD Role Example
VaultConnection Where Vault is (address, CA, TLS) values.yaml / k8s/01-vaultconnection.yaml
VaultAuth How to authenticate (method, mount, role, SA, audience) k8s/02-vaultauth.yaml
VaultAuthGlobal VaultAuth shared 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.

⚠️ Pitfalls#

  • 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.
    helm show values hashicorp/vault-secrets-operator --version 1.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.

📚 References#

Source: _k8s/vault-secret-operator/README.md12 sections
_k8s/vault-secret-operator/LISEZ-MOI.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/.

🎯 À quoi ça sert#

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.

Un contrat en miroir#

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

┌─────────────────────── Kubernetes (dossier k8s/) ───────────────────────┐
│  ServiceAccount  ──(token JWT projeté, audience "vault")──┐              │
│        ▲                                                  ▼              │
│  Deployment app        VaultAuth ── VaultConnection ── VSO (opérateur)   │
│        ▲  envFrom            │                            │              │
│   Secret K8s ◄── VaultStaticSecret / VaultDynamicSecret / VaultPKISecret │
└──────────────────────────────────────────┬──────────────────────────────┘
                                            │ login kubernetes + lecture
┌───────────────────────────────────────── ▼ ─── Vault (dossier vault/) ──┐
│  auth/kubernetes  ──(TokenReview valide le JWT)──►  role  ──►  policy    │
│                                                                 │        │
│  kv-v2 (static) · database (dynamic) · pki (certs) · transit (cache) ◄───┘│
└───────────────────────────────────────────────────────────────────────┘

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.

📋 Prérequis#

Prérequis Pourquoi Vérifier
Serveur Vault descellé tout part de là vault statusSealed false
Adresse Vault atteignable du cluster le VaultConnection par défaut vise http://vault.vault.svc.cluster.local:8200 kubectl -n vault get svc vault
API K8s joignable par Vault validation TokenReview. In-cluster : https://kubernetes.default.svc. Vault externe : la VIP https://192.168.56.5:6443 (cf. talos/patch-cp.yaml) vault read auth/kubernetes/config
helm + kubectl install de l'opérateur, application des CR helm version
CLI vault sur l'hôte lancer les scripts de vault/ vault version
Installer le CLI vault (Ubuntu, dépôt HashiCorp)
wget -qO- https://apt.releases.hashicorp.com/gpg \
  | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com noble main" \
  | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update && sudo apt-get install -y vault

La dist noble fournit un binaire générique, valable sur une Ubuntu plus récente.

⚡ Installation#

L'ordre compte : Vault d'abord (l'identité doit exister avant qu'un client tente de se logger), puis l'opérateur, puis les CR.

# 1. Côté Vault : auth kubernetes + moteurs + policies + roles      -> voir vault/LISEZ-MOI.md
export VAULT_ADDR=https://vault.talos.lab.example.io
export VAULT_TOKEN=<root-token>                                  # cf. ../vault-cluster/LISEZ-MOI.md
./_k8s/vault-secret-operator/vault/talos-lab.sh

# 2. L'opérateur (chart épinglé en 1.5.0)
helm repo add hashicorp https://helm.releases.hashicorp.com && helm repo update
helm upgrade --install vault-secrets-operator hashicorp/vault-secrets-operator \
  --namespace vault-secrets-operator --create-namespace \
  --version 1.5.0 \
  -f _k8s/vault-secret-operator/values.yaml
kubectl -n vault-secrets-operator rollout status deploy/vault-secrets-operator-controller-manager

# 3. Les CR côté cluster                                            -> voir k8s/LISEZ-MOI.md
kubectl apply -f _k8s/vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml

Chart 1.5.0 = app version 1.5.0. Pas de *-up.sh ici : les deux moitiés s'installent séparément et dans cet ordre.

🔧 Ce que règle values.yaml#

Réglage Valeur Effet
defaultVaultConnection.enabled true pose un VaultConnection 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.

✅ Vérifier#

kubectl -n vault-secrets-operator get pods
kubectl -n vault-secrets-operator logs deploy/vault-secrets-operator-controller-manager -f
kubectl get vaultconnection -A                  # le "default" posé par values.yaml
kubectl get vaultauth,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.

🧪 Scénarios#

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
kubectl apply -f _k8s/vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml

# La rotation, en direct : on change la valeur dans Vault…
vault kv put talos-lab/nginx-test-vault/config \
  APP_GREETING="Bonjour depuis Vault" APP_COLOR=green APP_SECRET_TOKEN=v2
# …VSO resync (refreshAfter 30s) -> Secret mis à jour -> rolloutRestartTargets relance le Deployment
kubectl -n nginx-test-vault rollout status deploy/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.

Vault (admin postgres) ──rotate password──► user PG « vault-rotate »
        │  database/static-creds/vault-rotate  (username + password courant + ttl)
        ▼
VSO (VaultDynamicSecret allowStaticCreds) ──SecretTransformation──► Secret K8s pg-rotate-creds
        │   DATABASE_URL + PGHOST/PGPORT/PGDATABASE/PGUSER/PGPASSWORD
        ▼
Deployment alpine (envFrom) ── rolloutRestartTargets ──► RELANCÉ à chaque rotation

Prérequis spécifiques (dans l'ordre) :

⚠️ 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 -n cnpg-demo get cluster pg-demo    # "Cluster in healthy state", 3/3 instances
# a. Accès superuser (Vault se connecte en admin "postgres" pour rotater le password)
kubectl -n cnpg-demo patch cluster pg-demo --type=merge \
  -p '{"spec":{"enableSuperuserAccess":true}}'

# b. Base "vault" + user "vault-rotate" créés une fois dans PG
PRIMARY=$(kubectl -n cnpg-demo get pods \
  -l cnpg.io/cluster=pg-demo,cnpg.io/instanceRole=primary -o jsonpath='{.items[0].metadata.name}')
# mot de passe bootstrap : il sera immédiatement remplacé par Vault
kubectl -n cnpg-demo exec "$PRIMARY" -c postgres -- psql -c \
  "CREATE ROLE \"vault-rotate\" WITH LOGIN PASSWORD 'bootstrap-temp-pw';"
kubectl -n cnpg-demo exec "$PRIMARY" -c postgres -- psql -c \
  "CREATE DATABASE vault OWNER \"vault-rotate\";"

Mise en route :

export VAULT_ADDR=http://127.0.0.1:8200   # kubectl -n vault port-forward svc/vault-active 8200:8200
export VAULT_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
kubectl apply -f _k8s/vault-secret-operator/k8s/pg-dynamic-rotate/pg-dynamic-rotate.yaml

Observer la rotation → le redémarrage :

vault read database/static-creds/vault-rotate       # username fixe, password + ttl qui bougent
vault write -f database/rotate-role/vault-rotate    # forcer une rotation immédiate
kubectl -n pg-rotate-demo get deploy pg-rotate-demo -o jsonpath='{.metadata.generation}'; echo
kubectl -n pg-rotate-demo get pods -l app=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.

🛡️ Les CRD et les bonnes pratiques appliquées#

CRD Rôle Exemple
VaultConnection est Vault (adresse, CA, TLS) values.yaml / k8s/01-vaultconnection.yaml
VaultAuth Comment s'authentifier (méthode, mount, role, SA, audience) k8s/02-vaultauth.yaml
VaultAuthGlobal VaultAuth mutualisé 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.

⚠️ Pièges#

  • 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.
    helm show values hashicorp/vault-secrets-operator --version 1.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.

📚 Références#

Source : _k8s/vault-secret-operator/LISEZ-MOI.md12 sections
_k8s/vault-secret-operator/k8s/README.md

🧾k8s/ — the CRs on the Kubernetes side

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

🎯 The chain of references#

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.

📋 Prerequisites#

Prerequisite Why Verify
VSO operator installed it is what reconciles the CRs kubectl -n vault-secrets-operator get deploy
Vault configured (../vault/) the identity must exist before the first login vault list auth/kubernetes/role
A reachable VaultConnection default created by ../values.yaml, or 01-vaultconnection.yaml kubectl get vaultconnection -A

Overall install order and the big picture: ../README.md.

⚡ Apply#

Path A — the lab demos (tested)#

Two self-contained manifests, each in its own namespace. They depend on ../vault/talos-lab.sh and ../vault/pg-dynamic-rotate.sh respectively.

kubectl apply -f _k8s/vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml
kubectl apply -f _k8s/vault-secret-operator/k8s/pg-dynamic-rotate/pg-dynamic-rotate.yaml

Path B — the numbered teaching CRs (namespace demo)#

kubectl apply -f 00-namespace-rbac.yaml     # ns "demo" + ServiceAccount "vso-app"
kubectl apply -f 01-vaultconnection.yaml    # optional if defaultVaultConnection is on
kubectl apply -f 02-vaultauth.yaml          # 3 VaultAuth: static / dynamic / pki
# 03 = multi-tenant variant (VaultAuthGlobal), INSTEAD OF 02

kubectl apply -f 10-static-kv.yaml          # KV-v2  -> Secret "static-kv"
kubectl apply -f 20-dynamic-db.yaml         # ephemeral DB creds (see ⚠️ Pitfalls: broken mount)
kubectl apply -f 30-pki-tls.yaml            # TLS certificate -> Secret "pki-tls"
kubectl apply -f 40-secrettransformation.yaml  # templating -> Secret "app-env"
kubectl apply -f 50-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 files#

File CRD What it does exactly
00-namespace-rbac.yaml Namespace, ServiceAccount ns demo + SA vso-app — must match the bound_service_account_* of the Vault roles
01-vaultconnection.yaml VaultConnection vault-conn in demohttp://vault.vault.svc.cluster.local:8200, skipTLSVerify: false
02-vaultauth.yaml VaultAuth ×3 vault-auth-static / -dynamic / -pki: mount kubernetes, SA vso-app, audiences: [vault], roles vso-static / vso-dynamic / vso-pki
03-vaultauthglobal.yaml VaultAuthGlobal + VaultAuth shared auth config in vault-secrets-operator, allowedNamespaces: [demo]; the VaultAuth then contributes nothing but its role
10-static-kv.yaml VaultStaticSecret mount: kvv2, path: demo/app → Secret static-kv, with rolloutRestartTargets on demo-app
20-dynamic-db.yaml VaultDynamicSecret mount: db, path: creds/demo-app, renewalPercent: 67, revoke: true → Secret dynamic-db
30-pki-tls.yaml VaultPKISecret mount: pki, role: demo, CN demo-app.talos.lab.example.io → Secret pki-tls (tls.crt/tls.key)
40-secrettransformation.yaml SecretTransformation + VaultStaticSecret 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]
SecretTransformation pg-rotate-dsn assembles DATABASE_URL + PGHOST/PGPORT/PGDATABASE/PGUSER/PGPASSWORD
VaultDynamicSecret pg-rotate mount: database, path: static-creds/vault-rotate, allowStaticCreds: true; excludes: [".*"] to keep only the templated keys → Secret pg-rotate-creds; rolloutRestartTargets → the Deployment
Deployment pg-rotate-demo alpine:3.20, envFrom on pg-rotate-creds: the DSN lands in its env vars

✅ Verify#

# Path B (ns demo)
kubectl -n demo get vaultauth,vaultstaticsecret,vaultdynamicsecret,vaultpkisecret
kubectl -n demo describe vaultstaticsecret static-kv     # events: "Secret synced"
kubectl -n demo get secret                               # static-kv, dynamic-db, pki-tls, app-env
kubectl -n demo get secret static-kv -o jsonpath='{.data.password}' | base64 -d ; echo
kubectl -n demo logs deploy/demo-app                     # the injected DB_/APP_ variables

# Path A — nginx: the secret -> env -> rollout loop
kubectl -n nginx-test-vault get vaultstaticsecret nginx-test-vault-config   # SecretSynced=True
POD=$(kubectl -n nginx-test-vault get pod -l app=nginx-test-vault -o jsonpath='{.items[0].metadata.name}')
kubectl -n nginx-test-vault exec "$POD" -- env | grep '^APP_'

# Path A — PostgreSQL: the rendered DSN + the proof of the restart
kubectl -n pg-rotate-demo get secret pg-rotate-creds -o jsonpath='{.data.DATABASE_URL}' | base64 -d; echo
kubectl -n pg-rotate-demo get deploy pg-rotate-demo -o jsonpath='{.metadata.generation}'; echo

# On a sync problem, the source of truth remains the operator logs:
kubectl -n vault-secrets-operator logs deploy/vault-secrets-operator-controller-manager -f

⚠️ Pitfalls#

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

📚 References#

Source: _k8s/vault-secret-operator/k8s/README.md11 sections
_k8s/vault-secret-operator/k8s/LISEZ-MOI.md

🧾k8s/ — les CR côté Kubernetes

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

🎯 La chaîne de références#

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.

📋 Prérequis#

Prérequis Pourquoi Vérifier
Opérateur VSO installé c'est lui qui réconcilie les CR kubectl -n vault-secrets-operator get deploy
Vault configuré (../vault/) l'identité doit exister avant le premier login vault list auth/kubernetes/role
Un VaultConnection joignable default posé par ../values.yaml, ou 01-vaultconnection.yaml kubectl get vaultconnection -A

Ordre global d'installation et vue d'ensemble : ../LISEZ-MOI.md.

⚡ Appliquer#

Parcours A — les démos du lab (testées)#

Deux manifestes autonomes, chacun dans son namespace. Ils dépendent de ../vault/talos-lab.sh et ../vault/pg-dynamic-rotate.sh respectivement.

kubectl apply -f _k8s/vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml
kubectl apply -f _k8s/vault-secret-operator/k8s/pg-dynamic-rotate/pg-dynamic-rotate.yaml

Parcours B — les CR pédagogiques numérotés (namespace demo)#

kubectl apply -f 00-namespace-rbac.yaml     # ns "demo" + ServiceAccount "vso-app"
kubectl apply -f 01-vaultconnection.yaml    # optionnel si defaultVaultConnection est actif
kubectl apply -f 02-vaultauth.yaml          # 3 VaultAuth : static / dynamic / pki
# 03 = variante multi-tenant (VaultAuthGlobal), À LA PLACE de 02

kubectl apply -f 10-static-kv.yaml          # KV-v2  -> Secret "static-kv"
kubectl apply -f 20-dynamic-db.yaml         # creds DB éphémères (voir ⚠️ Pièges : mount cassé)
kubectl apply -f 30-pki-tls.yaml            # certificat TLS -> Secret "pki-tls"
kubectl apply -f 40-secrettransformation.yaml  # templating -> Secret "app-env"
kubectl apply -f 50-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).

🔧 Les fichiers#

Fichier CRD Ce qu'il fait exactement
00-namespace-rbac.yaml Namespace, ServiceAccount ns demo + SA vso-app — doivent matcher bound_service_account_* des roles Vault
01-vaultconnection.yaml VaultConnection vault-conn dans demohttp://vault.vault.svc.cluster.local:8200, skipTLSVerify: false
02-vaultauth.yaml VaultAuth ×3 vault-auth-static / -dynamic / -pki : mount kubernetes, SA vso-app, audiences: [vault], roles vso-static / vso-dynamic / vso-pki
03-vaultauthglobal.yaml VaultAuthGlobal + VaultAuth config d'auth mutualisée dans vault-secrets-operator, allowedNamespaces: [demo] ; le VaultAuth n'apporte plus que son role
10-static-kv.yaml VaultStaticSecret mount: kvv2, path: demo/app → Secret static-kv, avec rolloutRestartTargets sur demo-app
20-dynamic-db.yaml VaultDynamicSecret mount: db, path: creds/demo-app, renewalPercent: 67, revoke: true → Secret dynamic-db
30-pki-tls.yaml VaultPKISecret mount: pki, role: demo, CN demo-app.talos.lab.example.io → Secret pki-tls (tls.crt/tls.key)
40-secrettransformation.yaml SecretTransformation + VaultStaticSecret transformation app-env (DATABASE_URL, APP_PASSWORD, excludeRaw: true) + le CR static-kv-templated qui l'utilise
50-demo-deployment.yaml Deployment busybox:1.36 : envFrom sur static-kv, env clé par clé sur dynamic-db, volume monté depuis pki-tls

nginx-test-vault/ — secret KV du lab → variables d'env → rollout#

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]
SecretTransformation pg-rotate-dsn assemble DATABASE_URL + PGHOST/PGPORT/PGDATABASE/PGUSER/PGPASSWORD
VaultDynamicSecret pg-rotate 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

✅ Vérifier#

# Parcours B (ns demo)
kubectl -n demo get vaultauth,vaultstaticsecret,vaultdynamicsecret,vaultpkisecret
kubectl -n demo describe vaultstaticsecret static-kv     # events : "Secret synced"
kubectl -n demo get secret                               # static-kv, dynamic-db, pki-tls, app-env
kubectl -n demo get secret static-kv -o jsonpath='{.data.password}' | base64 -d ; echo
kubectl -n demo logs deploy/demo-app                     # les variables DB_/APP_ injectées

# Parcours A — nginx : la boucle secret -> env -> rollout
kubectl -n nginx-test-vault get vaultstaticsecret nginx-test-vault-config   # SecretSynced=True
POD=$(kubectl -n nginx-test-vault get pod -l app=nginx-test-vault -o jsonpath='{.items[0].metadata.name}')
kubectl -n nginx-test-vault exec "$POD" -- env | grep '^APP_'

# Parcours A — PostgreSQL : la DSN rendue + la preuve du redémarrage
kubectl -n pg-rotate-demo get secret pg-rotate-creds -o jsonpath='{.data.DATABASE_URL}' | base64 -d; echo
kubectl -n pg-rotate-demo get deploy pg-rotate-demo -o jsonpath='{.metadata.generation}'; echo

# Sur un problème de synchro, la source de vérité reste les logs de l'opérateur :
kubectl -n vault-secrets-operator logs deploy/vault-secrets-operator-controller-manager -f

⚠️ Pièges#

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

📚 Références#

Source : _k8s/vault-secret-operator/k8s/LISEZ-MOI.md11 sections
_k8s/vault-secret-operator/vault/README.md

⚙️vault/ — the configuration on the Vault side

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

🎯 The identity contract#

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.

📋 Prerequisites#

Prerequisite Why Verify
vault CLI on the host every script calls it vault version
VAULT_ADDR exported otherwise the CLI hits https://127.0.0.1:8200 echo $VAULT_ADDR
VAULT_TOKEN exported (root/admin) enabling engines and writing policies vault token lookup
Vault unsealed a sealed Vault refuses everything vault statusSealed false
KUBECONFIG (only for pg-dynamic-rotate.sh) reads the CNPG superuser password kubectl get nodes
export VAULT_ADDR="https://vault.talos.lab.example.io"   # or http://127.0.0.1:8200 via port-forward
export VAULT_TOKEN="<root-token>"                    # see ../../vault-cluster/README.md
vault status                                         # 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).

⚡ Two paths#

The directory holds two sets of scripts that do not serve the same purpose. Do not mix them: they use different mounts, namespaces and role names.

Path A — the real lab (tested)#

./_k8s/vault-secret-operator/vault/talos-lab.sh          # k8s auth + talos-lab/ engine + demo
./_k8s/vault-secret-operator/vault/pg-dynamic-rotate.sh  # database/ engine + PG rotation

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
bash 00-secrets-engines.sh                 # engines + a demo secret + PKI CA + transit key
MODE=incluster bash 01-kubernetes-auth.sh   # auth/kubernetes (see the pitfall below!)
bash 02-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.

🔧 What each script writes into Vault#

00-secrets-engines.sh — the secrets engines#

Vault object Command Note
kvv2/ vault secrets enable -path=kvv2 -version=2 kv static secrets
kvv2/demo/app vault kv put … username password demo secret, overwritten on every run
database/ vault secrets enable database ⚠️ mounted on database/, not db/ — see Pitfalls
pki/ enable -path=pki pki + secrets tune -max-lease-ttl=87600h 10 years of max lease
PKI root CA vault write pki/root/generate/internal common_name=$LAB_DOMAIN ttl=87600h LAB_DOMAIN (lab.env, default talos.lab.example.io); ⚠️ stacks one CA per run — see Pitfalls
pki/config/urls issuing_certificates + crl_distribution_points on $VAULT_ADDR depends on VAULT_ADDR
pki/roles/demo allowed_domains=$LAB_DOMAIN allow_subdomains=true max_ttl=72h RSA 2048 bounds what the VaultPKISecret may request — the CN of 30-pki-tls.yaml must stay inside it
transit/ + key vso-client-cache enable transit ; write -f transit/keys/vso-client-cache encryption of the VSO client cache

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

01-kubernetes-auth.sh — the auth method#

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:

  1. a delegator ServiceAccount on the K8s side (system:auth-delegator);
  2. its long-lived token (token_reviewer_jwt), with which Vault will validate the apps' JWTs;
  3. 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).
MODE=external KUBE_HOST=https://192.168.56.5:6443 SA_JWT= SA_CA_CRT= bash 01-kubernetes-auth.sh

The kubectl commands that produce SA_JWT / SA_CA_CRT are commented out in the script.

02-roles.sh — policies + roles#

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

Vault role Bound SA / namespace Policy
vso-static vso-app / demo vso-static-kv
vso-dynamic vso-app / demo vso-dynamic-db
vso-pki vso-app / demo vso-pki
vso-transit vault-secrets-operator-controller-manager / vault-secrets-operator vso-transit

vso-transit is the role of the operator itself (encryption of its client cache), not of an app.

talos-lab.sh — the lab engine#

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.

pg-dynamic-rotate.sh — PostgreSQL password rotation#

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.

Vault object Command (summary) Purpose
database/ vault secrets enable database the "database" engine
database/config/pg-demo plugin_name=postgresql-database-plugin allowed_roles=vault-rotate connection_url=…@pg-demo-rw.cnpg-demo…/postgres?sslmode=require username=postgres password=<superuser> password_authentication=scram-sha-256 where Vault connects and how (admin postgres, TLS). The password is read from the pg-demo-superuser Secret by the script.
database/static-roles/vault-rotate db_name=pg-demo username=vault-rotate rotation_period=$ROTATION_PERIOD takes over the fixed PG user. db_name = the name of the connection, not of the database. ROTATION_PERIOD defaults to 3h.
Policy pg-rotate-demo path "database/static-creds/vault-rotate" { capabilities = ["read"] } minimal right: read that one static-creds
Role auth/kubernetes/role/pg-rotate-demo SA pg-rotate / ns pg-rotate-demo, audience=vault who may log in and which rights they get
vault read database/static-creds/vault-rotate     # username (fixed) + current password + remaining ttl
vault write -f database/rotate-role/vault-rotate  # force an immediate rotation

🛡️ The policies#

One policy = one use, scoped to the exact path. No mount wildcard.

File Allows
policies/vso-static-kv.hcl read on kvv2/data/demo/app + kvv2/metadata/demo/app
policies/vso-dynamic-db.hcl read on db/creds/demo-app (⚠️ mount db/ — see Pitfalls) + update on sys/leases/renew|revoke
policies/vso-pki.hcl create/update on pki/issue/demo and pki/revoke
policies/vso-transit-cache.hcl encrypt/decrypt with the vso-client-cache key (operator)
policies/talos-lab-nginx-test-vault.hcl read on talos-lab/data|metadata/nginx-test-vault/*

ℹ️ KV-v2: the policy path is <mount>/data/<path> for the data and <mount>/metadata/<path> for the versions — not <mount>/<path>. Classic mistake.

✅ Verify#

vault status                                       # Sealed=false
vault secrets list                                 # kvv2/, database/, pki/, transit/, talos-lab/
vault auth list                                    # kubernetes/ present
vault read auth/kubernetes/config                  # kubernetes_host MUST be a real URL
vault list auth/kubernetes/role                     # vso-*, nginx-test-vault, pg-rotate-demo
vault policy list
vault kv get kvv2/demo/app                          # demo secret (path B)
vault kv get talos-lab/nginx-test-vault/config       # lab secret (path A)
vault list pki/issuers                              # exactly ONE CA expected (see Pitfalls)

# Dry-run login test, without going through VSO — isolates identity problems:
JWT=$(kubectl -n demo create token vso-app --audience=vault)
vault write auth/kubernetes/login role=vso-static jwt="$JWT"   # must return a token + its policy

⚠️ Pitfalls#

  • 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:
    vault secrets enable -path=db database      # instead of: vault secrets enable database
    
    (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:
    vault read auth/kubernetes/config     # if kubernetes_host contains a "$", this is the bug
    vault write auth/kubernetes/config kubernetes_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/internal no 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 -uunbound 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).

📚 References#

Source: _k8s/vault-secret-operator/vault/README.md15 sections
_k8s/vault-secret-operator/vault/LISEZ-MOI.md

⚙️vault/ — la configuration côté Vault

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

🎯 Le contrat d'identité#

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.

📋 Prérequis#

Prérequis Pourquoi Vérifier
CLI vault sur l'hôte tous les scripts l'appellent vault version
VAULT_ADDR exporté sinon le CLI tape https://127.0.0.1:8200 echo $VAULT_ADDR
VAULT_TOKEN exporté (root/admin) activer des moteurs et écrire des policies vault token lookup
Vault descellé un Vault scellé refuse tout vault statusSealed false
KUBECONFIG (uniquement pg-dynamic-rotate.sh) lit le mot de passe superuser CNPG kubectl get nodes
export VAULT_ADDR="https://vault.talos.lab.example.io"   # ou http://127.0.0.1:8200 en port-forward
export VAULT_TOKEN="<root-token>"                    # cf. ../../vault-cluster/LISEZ-MOI.md
vault status                                         # 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).

⚡ Deux parcours#

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.

Parcours A — le lab réel (testé)#

./_k8s/vault-secret-operator/vault/talos-lab.sh          # auth k8s + moteur talos-lab/ + démo
./_k8s/vault-secret-operator/vault/pg-dynamic-rotate.sh  # moteur database/ + rotation PG

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
bash 00-secrets-engines.sh                 # moteurs + un secret de démo + CA PKI + clé transit
MODE=incluster bash 01-kubernetes-auth.sh   # auth/kubernetes (voir le piège plus bas !)
bash 02-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.

🔧 Ce que chaque script écrit dans Vault#

00-secrets-engines.sh — les moteurs de secrets#

Objet Vault Commande Note
kvv2/ vault secrets enable -path=kvv2 -version=2 kv secrets statiques
kvv2/demo/app vault kv put … username password secret de démo, écrasé à chaque relance
database/ vault secrets enable database ⚠️ monté sur database/, pas db/ — cf. Pièges
pki/ enable -path=pki pki + secrets tune -max-lease-ttl=87600h 10 ans de bail max
CA racine PKI vault write pki/root/generate/internal common_name=$LAB_DOMAIN ttl=87600h LAB_DOMAIN (lab.env, défaut talos.lab.example.io) ; ⚠️ empile une CA par relance — cf. Pièges
pki/config/urls issuing_certificates + crl_distribution_points sur $VAULT_ADDR dépend de VAULT_ADDR
pki/roles/demo allowed_domains=$LAB_DOMAIN allow_subdomains=true max_ttl=72h RSA 2048 borne ce que le VaultPKISecret peut demander — le CN de 30-pki-tls.yaml doit y rester
transit/ + clé vso-client-cache enable transit ; write -f transit/keys/vso-client-cache chiffrement du cache client VSO

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

01-kubernetes-auth.sh — la méthode d'auth#

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 :

  1. un ServiceAccount délégateur côté K8s (system:auth-delegator) ;
  2. son token long (token_reviewer_jwt), avec lequel Vault validera les JWT des apps ;
  3. 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).
MODE=external KUBE_HOST=https://192.168.56.5:6443 SA_JWT= SA_CA_CRT= bash 01-kubernetes-auth.sh

Les commandes kubectl qui produisent SA_JWT / SA_CA_CRT sont en commentaire dans le script.

02-roles.sh — policies + roles#

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

Role Vault SA / namespace bindé Policy
vso-static vso-app / demo vso-static-kv
vso-dynamic vso-app / demo vso-dynamic-db
vso-pki vso-app / demo vso-pki
vso-transit vault-secrets-operator-controller-manager / vault-secrets-operator vso-transit

vso-transit est le role de l'opérateur lui-même (chiffrement de son cache client), pas d'une app.

talos-lab.sh — le moteur du lab#

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.

Objet Vault Commande (résumé) Rôle
database/ vault secrets enable database moteur « base de données »
database/config/pg-demo plugin_name=postgresql-database-plugin allowed_roles=vault-rotate connection_url=…@pg-demo-rw.cnpg-demo…/postgres?sslmode=require username=postgres password=<superuser> password_authentication=scram-sha-256 Vault se connecte et comment (admin postgres, TLS). Le mot de passe est lu dans le Secret pg-demo-superuser par le script.
database/static-roles/vault-rotate db_name=pg-demo username=vault-rotate rotation_period=$ROTATION_PERIOD prend en gestion le user PG fixe. db_name = nom de la connexion, pas de la base. ROTATION_PERIOD par défaut 3h.
Policy pg-rotate-demo path "database/static-creds/vault-rotate" { capabilities = ["read"] } droit minimal : lire ce seul static-creds
Role auth/kubernetes/role/pg-rotate-demo SA pg-rotate / ns pg-rotate-demo, audience=vault qui peut se logger et quels droits il reçoit
vault read database/static-creds/vault-rotate     # username (fixe) + password courant + ttl restant
vault write -f database/rotate-role/vault-rotate  # forcer une rotation immédiate

🛡️ Les policies#

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.

✅ Vérifier#

vault status                                       # Sealed=false
vault secrets list                                 # kvv2/, database/, pki/, transit/, talos-lab/
vault auth list                                    # kubernetes/ présent
vault read auth/kubernetes/config                  # kubernetes_host DOIT être une vraie URL
vault list auth/kubernetes/role                     # vso-*, nginx-test-vault, pg-rotate-demo
vault policy list
vault kv get kvv2/demo/app                          # secret de démo (parcours B)
vault kv get talos-lab/nginx-test-vault/config       # secret du lab (parcours A)
vault list pki/issuers                              # UNE seule CA attendue (cf. Pièges)

# Test de login « à blanc », sans passer par VSO — isole les problèmes d'identité :
JWT=$(kubectl -n demo create token vso-app --audience=vault)
vault write auth/kubernetes/login role=vso-static jwt="$JWT"   # doit renvoyer un token + sa policy

⚠️ Pièges#

  • 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 :
    vault secrets enable -path=db database      # 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 :
    vault read auth/kubernetes/config     # si kubernetes_host contient un "$", c'est ce bug
    vault write auth/kubernetes/config kubernetes_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/internal n'é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 -uunbound 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).

📚 Références#

Source : _k8s/vault-secret-operator/vault/LISEZ-MOI.md15 sections
_k8s/cloudnative-pg/README.md

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

🎯 Purpose#

  • 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)#

  1. PostgreSQL replication (logical): the primary streams its WAL to 2 replicas → application-level failover if the primary is lost.
  2. 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.

📋 Prerequisites#

Prerequisite Why Verify
Longhorn (../longhorn/) provides the CSI that provisions the PVCs kubectl -n longhorn-system get pods
longhorn-r1 StorageClass (../longhorn/longhorn-r1-storageclass.yaml) 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).

⚡ Install#

kubectl apply -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).

🔧 What the script does#

  1. checks kubectl/helm, the apiserver, and that the longhorn-r1 SC is present;
  2. installs the operator in cnpg-system with values.yaml, then waits for the rollout;
  3. applies cluster-demo.yaml (namespace cnpg-demo + Cluster pg-demo) and waits for condition=Ready (300 s max, without failing if the deadline is exceeded).

Files#

File Purpose
values.yaml Helm values for the operator (1 replica, podMonitorEnabled: false)
cluster-demo.yaml Namespace cnpg-demo + Cluster pg-demo (3 instances, 1Gi RWO on longhorn-r1, max_connections=100, shared_buffers=128MB)
cloudnative-pg-up.sh Installs the operator + applies the demo cluster
pg-backup-vault-s3.yaml / pg-backup-up.sh Hourly logical backup (pg_dump) to MinIO, with the Vault creds
pg-app-backup-cnpg.yaml / pg-app-backup-cnpg-up.sh Native CNPG backup (physical + WAL, PITR) to MinIO

What the operator creates for you#

Resource Purpose
Secret pg-demo-app Application credentials (user, password, dbname, host, uri)
Service pg-demo-rw Read/write → always the primary
Service pg-demo-ro Read-only → replicas (read load balancing)
Service pg-demo-r All nodes (primary + replicas)
Secret pg-demo-superuser Only if enableSuperuserAccess: true — absent by default (see ⚠️ Pitfalls)

✅ Verify#

kubectl -n cnpg-demo get cluster pg-demo                       # READY 3/3, "Cluster in healthy state"
kubectl -n cnpg-demo get pods -l cnpg.io/cluster=pg-demo       # pg-demo-1/2/3 Running, 1 per worker
kubectl -n cnpg-demo get pvc                                   # 3 PVCs Bound, 1Gi longhorn-r1

# Connect and read (through the primary pod)
kubectl -n cnpg-demo exec -it pg-demo-1 -- psql -c '\l'        # lists the databases (including `app`)

The kubectl-cnpg plugin gives a richer view (install it on the host, optional):

kubectl cnpg status pg-demo -n cnpg-demo

🧪 Scenarios#

1. Automatic failover (the showstopper)#

kubectl -n cnpg-demo get cluster pg-demo -o jsonpath='{.status.currentPrimary}'; echo  # e.g. pg-demo-1
kubectl -n cnpg-demo delete pod pg-demo-1                       # kill the primary
watch kubectl -n cnpg-demo get cluster pg-demo                  # a replica is promoted within seconds

The operator promotes a replica and recreates the old primary as a replica, with no intervention.

2. Persistence on Longhorn#

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

3. Consuming the database from an app#

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/).

kubectl -n cnpg-demo get secret pg-demo-app -o jsonpath='{.data.uri}' | base64 -d; echo

4. Scaling the replicas#

kubectl -n cnpg-demo patch cluster pg-demo --type merge -p '{"spec":{"instances":2}}'  # 3→2
# (scale back to 3 afterwards; watch the rebalancing)

💽 Backups — two mechanisms living side by side#

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

pg_dump-via-Vault (pg-backup-vault-s3.yaml) Native CNPG (pg-app-backup-cnpg.yaml)
Goes through the CNPG CRDs ❌ no (plain CronJob) ✅ yes (Backup / ScheduledBackup)
Type Logical (pg_dump | gzip) Physical (base backup + WAL archiving)
Scope 1 database (vault) The whole pg-demo instance (including app)
PG credentials Vault (pg-rotate-creds, rotated) CNPG-internal
Frequency CronJob 0 * * * * (hourly) ScheduledBackup 0 0 * * * * (hourly) + continuous WAL
PITR (restore to a point in time) ❌ no ✅ yes
Bucket / retention pg-backupsno expiration cnpg-backupsretentionPolicy: 7d

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 -n pg-rotate-demo create job pg-backup-now --from=cronjob/pg-backup-vault-s3
kubectl -n pg-rotate-demo logs job/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
# dedicated MinIO bucket/user + Secret cnpg-backup-s3 + barmanObjectStore patch
# (+ retentionPolicy 7d) + ScheduledBackup + 1 immediate backup
./_k8s/cloudnative-pg/pg-app-backup-cnpg-up.sh

# Verify
kubectl -n cnpg-demo get backups                       # phase=completed, method=barmanObjectStore
kubectl -n cnpg-demo get cluster pg-demo \
  -o jsonpath='{.status.firstRecoverabilityPoint}{"\n"}'   # PITR starting point (not empty)
mc ls -r <alias>/cnpg-backups/pg-demo/                 # base/… + wals/…

💡 Restore (PITR): you create a new Cluster 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.

🚑 Troubleshooting#

  • 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 Pendinglonghorn-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.

⚠️ Pitfalls#

  • Longhorn faulted / ReplicaSchedulingFailure: insufficient storagealready 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:
    kubectl -n cnpg-demo patch cluster pg-demo --type=merge \
      -p '{"spec":{"enableSuperuserAccess":true}}'
    
  • 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) and monitoring.podMonitorEnabled: true in values.yaml (operator metrics), then re-run the script. The PodMonitor CRD only exists after kube-prometheus-stack — hence the order.

📚 References#

Source: _k8s/cloudnative-pg/README.md19 sections
_k8s/cloudnative-pg/LISEZ-MOI.md

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

🎯 À quoi ça sert#

  • 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)#

  1. Réplication PostgreSQL (logique) : le primaire streame ses WAL vers 2 réplicas → bascule applicative en cas de perte du primaire.
  2. 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.

📋 Prérequis#

Prérequis Pourquoi Vérifier
Longhorn (../longhorn/) fournit le CSI qui provisionne les PVC kubectl -n longhorn-system get pods
StorageClass longhorn-r1 (../longhorn/longhorn-r1-storageclass.yaml) 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).

⚡ Installation#

kubectl apply -f _k8s/longhorn/longhorn-r1-storageclass.yaml   # si pas déjà fait
./_k8s/cloudnative-pg/cloudnative-pg-up.sh

Version épinglée dans le script : chart cnpg/cloudnative-pg 0.29.0 (app v1.30.0), surchargeable par CNPG_VERSION=…. Idempotent (helm upgrade --install + kubectl apply).

🔧 Ce que fait le script#

  1. vérifie kubectl/helm, l'apiserver, et la présence de la SC longhorn-r1 ;
  2. installe l'opérateur dans cnpg-system avec values.yaml, puis attend le rollout ;
  3. applique cluster-demo.yaml (namespace cnpg-demo + Cluster pg-demo) et attend condition=Ready (300 s max, sans échouer si le délai est dépassé).

Fichiers#

Fichier Rôle
values.yaml Valeurs Helm de l'opérateur (1 replica, podMonitorEnabled: false)
cluster-demo.yaml Namespace cnpg-demo + Cluster pg-demo (3 instances, 1Gi RWO sur longhorn-r1, max_connections=100, shared_buffers=128MB)
cloudnative-pg-up.sh Installe l'opérateur + applique le cluster de démo
pg-backup-vault-s3.yaml / pg-backup-up.sh Backup logique horaire (pg_dump) vers MinIO, avec les creds Vault
pg-app-backup-cnpg.yaml / pg-app-backup-cnpg-up.sh Backup natif CNPG (physique + WAL, PITR) vers MinIO

Ce que l'opérateur crée pour toi#

Ressource Rôle
Secret pg-demo-app Identifiants applicatifs (user, password, dbname, host, uri)
Service pg-demo-rw Lecture/écriture → toujours le primaire
Service pg-demo-ro Lecture seule → réplicas (répartition de charge lecture)
Service pg-demo-r Tous les nœuds (primaire + réplicas)
Secret pg-demo-superuser Seulement si enableSuperuserAccess: true — absent par défaut (cf. ⚠️ Pièges)

✅ Vérifier#

kubectl -n cnpg-demo get cluster pg-demo                       # READY 3/3, "Cluster in healthy state"
kubectl -n cnpg-demo get pods -l cnpg.io/cluster=pg-demo       # pg-demo-1/2/3 Running, 1 par worker
kubectl -n cnpg-demo get pvc                                   # 3 PVC Bound, 1Gi longhorn-r1

# Se connecter et lire (via le pod primaire)
kubectl -n cnpg-demo exec -it pg-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 cnpg status pg-demo -n cnpg-demo

🧪 Scénarios#

1. Failover automatique (le clou du spectacle)#

kubectl -n cnpg-demo get cluster pg-demo -o jsonpath='{.status.currentPrimary}'; echo  # ex: pg-demo-1
kubectl -n cnpg-demo delete pod pg-demo-1                       # on tue le primaire
watch kubectl -n cnpg-demo get cluster pg-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.

2. Persistance sur Longhorn#

É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).

3. Consommer la base depuis une app#

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/).

kubectl -n cnpg-demo get secret pg-demo-app -o jsonpath='{.data.uri}' | base64 -d; echo

4. Scale des réplicas#

kubectl -n cnpg-demo patch cluster pg-demo --type merge -p '{"spec":{"instances":2}}'  # 3→2
# (repasser à 3 ensuite ; observer le rebalancing)

💽 Sauvegardes — deux mécanismes qui coexistent#

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

pg_dump-via-Vault (pg-backup-vault-s3.yaml) Natif CNPG (pg-app-backup-cnpg.yaml)
Passe par les CRD CNPG ❌ non (CronJob standard) ✅ oui (Backup / ScheduledBackup)
Type Logique (pg_dump | gzip) Physique (base backup + archivage WAL)
Portée 1 base (vault) Toute l'instance pg-demo (dont app)
Identifiants PG Vault (pg-rotate-creds, rotés) Internes CNPG
Fréquence CronJob 0 * * * * (horaire) ScheduledBackup 0 0 * * * * (horaire) + WAL en continu
PITR (restauration à T) ❌ non ✅ oui
Bucket / rétention pg-backupsaucune expiration cnpg-backupsretentionPolicy: 7d

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.

CronJob pg-backup-vault-s3 (ns pg-rotate-demo, schedule "0 * * * *")
   │  pg_dump "$DATABASE_URL"  (creds Vault du Secret pg-rotate-creds, sslmode=require)
   ▼  vault-<timestamp>.sql.gz
   └─ mc cp ──► bucket MinIO pg-backups   (utilisateur MinIO dédié pg-backup, scopé au bucket)

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 -n pg-rotate-demo create job pg-backup-now --from=cronjob/pg-backup-vault-s3
kubectl -n pg-rotate-demo logs job/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.

Cluster pg-demo  ── spec.backup.barmanObjectStore ──►  bucket MinIO cnpg-backups/pg-demo/
   ├─ base/<timestamp>/data.tar.gz   (base backup, déclenché par Backup/ScheduledBackup)
   └─ wals/.../*.gz                  (archivage WAL CONTINU => PITR)
ScheduledBackup pg-demo-hourly  ── "0 0 * * * *" (cron 6 champs : sec min h …) ──► base backups
# bucket/user MinIO dédiés + Secret cnpg-backup-s3 + patch barmanObjectStore
# (+ retentionPolicy 7d) + ScheduledBackup + 1 backup immédiat
./_k8s/cloudnative-pg/pg-app-backup-cnpg-up.sh

# Vérifier
kubectl -n cnpg-demo get backups                       # phase=completed, method=barmanObjectStore
kubectl -n cnpg-demo get cluster pg-demo \
  -o jsonpath='{.status.firstRecoverabilityPoint}{"\n"}'   # point de départ PITR (non vide)
mc ls -r <alias>/cnpg-backups/pg-demo/                 # base/… + wals/…

💡 Restauration (PITR) : on crée un nouveau Cluster 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 ».

🚑 Dépannage#

  • 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é LonghorndefaultReplicaCount 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.

⚠️ Pièges#

  • Longhorn faulted / ReplicaSchedulingFailure: insufficient storagedé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 :
    kubectl -n cnpg-demo patch cluster pg-demo --type=merge \
      -p '{"spec":{"enableSuperuserAccess":true}}'
    
  • 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) et monitoring.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.

📚 Références#

Source : _k8s/cloudnative-pg/LISEZ-MOI.md19 sections
_k8s/observability/README.md

📈observability/ — metrics (Prometheus/Grafana) + logs (Loki/Alloy)

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.

🎯 Purpose#

  • Backing for the PromQL / dashboards / alerting modules (Prometheus + Grafana + Alertmanager).
  • Centralized logs: Alloy reads /var/log/pods on every node → Loki → Grafana's Explore tab (Grafana is pre-wired with both datasources).
  • The base you hook the other components' metrics onto (⚠️ nothing is hooked up by default, see Pitfalls).

Two important design choices#

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

📋 Prerequisites#

Prerequisite Why Verify
../platform-up.sh (Cilium + Envoy Gateway + cert-manager) exposes the 3 UIs over HTTPS:443 with the wildcard cert kubectl get gateway -n envoy-gateway-system
Longhorn + longhorn-r1 SC (../longhorn/longhorn-r1-storageclass.yaml) 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.env before installing, then vagrant reload the CPs one at a time.

⚡ Install#

kubectl apply -f _k8s/longhorn/longhorn-r1-storageclass.yaml   # if not already done
./_k8s/observability/observability-up.sh

Versions pinned in the script (overridable by env var):

Chart Version App
prometheus-community/kube-prometheus-stack 87.19.0 (KPS_VERSION) Prometheus Operator v0.92.1
grafana/loki 7.1.0 (LOKI_VERSION) Loki v3.6.8
grafana/alloy 1.11.0 (ALLOY_VERSION) Alloy v1.18.0

🔧 What the script does#

  1. monitoring namespace in PodSecurity privileged (node-exporter in hostNetwork/hostPath
    • Alloy with a hostPath on /var/log/pods);
  2. kube-prometheus-stack → waits for the Grafana rollout;
  3. Loki (SingleBinary, filesystem) → waits for the StatefulSet;
  4. Alloy (DaemonSet) → waits for the DaemonSet;
  5. HTTPRoutes grafana / prometheus / alertmanager.

Files#

File Purpose
namespace.yaml ns monitoring in PodSecurity privileged
kube-prometheus-stack-values.yaml Prometheus (retention: 2d, 3Gi PVC on longhorn-r1) + Grafana (1Gi PVC + Loki datasource) + Alertmanager (emptyDir); Talos control-plane monitors disabled; scrapes every ServiceMonitor/PodMonitor
loki-values.yaml 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.

✅ Verify#

kubectl -n monitoring get pods                         # all Running (including 1 alloy per node)
kubectl -n monitoring get httproute                    # grafana/prometheus/alertmanager

# Endpoints (wildcard cert; --resolve bypasses DNS). -k if the cert is staging.
for h in grafana prometheus alertmanager; do
  curl -sk -o /dev/null -w "$h -> %{http_code}\n" \
    --resolve $h.talos.lab.example.io:443:192.168.56.200 https://$h.talos.lab.example.io/
done   # expected: grafana 302, prometheus 302, alertmanager 200

# Logs actually landing in Loki (labels set by Alloy):
kubectl -n monitoring exec deploy/loki-gateway -- \
  wget -qO- http://localhost:8080/loki/api/v1/labels     # app, container, namespace, pod…

🌐 Access#

Service URL Username Password
Grafana https://grafana.talos.lab.example.io admin kubectl -n monitoring get secret kube-prometheus-stack-grafana -o jsonpath='{.data.admin-password}' | base64 -d; echo
Prometheus https://prometheus.talos.lab.example.io no authentication
Alertmanager https://alertmanager.talos.lab.example.io no authentication

🚑 Troubleshooting#

  • 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 / ReplicaSchedulingFailurelonghorn-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 servicetalosctl logs etcd (shipping it to Loki would need a dedicated shipper).

⚠️ Pitfalls#

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

    kubectl -n monitoring get cm loki -o jsonpath='{.data.config\.yaml}' \
      | grep -A4 '^compactor:'
    
  • 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 true after this install (the ServiceMonitor/PodMonitor CRDs only exist afterwards), then re-run the *-up.sh of the component concerned:

    File Key to set to true
    ../trivy-operator/values.yaml serviceMonitor.enabled
    ../cloudnative-pg/values.yaml monitoring.podMonitorEnabled (operator)
    ../cloudnative-pg/cluster-demo.yaml monitoring.enablePodMonitor (PG instances)
    ../node-problem-detector/values.yaml metrics.serviceMonitor.enabled
    ../vault-secret-operator/values.yaml telemetry.serviceMonitor.enabled

📚 References#

Source: _k8s/observability/README.md11 sections
_k8s/observability/LISEZ-MOI.md

📈observability/ — métriques (Prometheus/Grafana) + logs (Loki/Alloy)

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.

🎯 À quoi ça sert#

  • Support des modules PromQL / dashboards / alerting (Prometheus + Grafana + Alertmanager).
  • Logs centralisés : Alloy lit /var/log/pods sur chaque node → Loki → onglet Explore de Grafana (Grafana est pré-câblé avec les deux datasources).
  • Base sur laquelle brancher les métriques des autres addons (⚠️ rien n'est branché par défaut, cf. Pièges).

Deux partis-pris importants#

  • 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é.

📋 Prérequis#

Prérequis Pourquoi Vérifier
../platform-up.sh (Cilium + Envoy Gateway + cert-manager) expose les 3 UI en HTTPS:443 avec le cert wildcard kubectl get gateway -n envoy-gateway-system
Longhorn + SC longhorn-r1 (../longhorn/longhorn-r1-storageclass.yaml) 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.env avant d'installer, puis vagrant reload des CP un par un.

⚡ Installation#

kubectl apply -f _k8s/longhorn/longhorn-r1-storageclass.yaml   # si pas déjà fait
./_k8s/observability/observability-up.sh

Versions épinglées dans le script (surchargeables par variable d'env) :

Chart Version App
prometheus-community/kube-prometheus-stack 87.19.0 (KPS_VERSION) Prometheus Operator v0.92.1
grafana/loki 7.1.0 (LOKI_VERSION) Loki v3.6.8
grafana/alloy 1.11.0 (ALLOY_VERSION) Alloy v1.18.0

🔧 Ce que fait le script#

  1. namespace monitoring en PodSecurity privileged (node-exporter en hostNetwork/hostPath
    • Alloy en hostPath sur /var/log/pods) ;
  2. kube-prometheus-stack → attend le rollout de Grafana ;
  3. Loki (SingleBinary, filesystem) → attend le StatefulSet ;
  4. Alloy (DaemonSet) → attend le DaemonSet ;
  5. HTTPRoutes grafana / prometheus / alertmanager.

Fichiers#

Fichier Rôle
namespace.yaml ns monitoring en PodSecurity privileged
kube-prometheus-stack-values.yaml Prometheus (retention: 2d, PVC 3Gi longhorn-r1) + Grafana (PVC 1Gi + datasource Loki) + Alertmanager (emptyDir) ; moniteurs control-plane Talos désactivés ; scrape tous les ServiceMonitor/PodMonitor
loki-values.yaml 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.

✅ Vérifier#

kubectl -n monitoring get pods                         # tout Running (dont 1 alloy par node)
kubectl -n monitoring get httproute                    # grafana/prometheus/alertmanager

# Endpoints (cert wildcard ; --resolve court-circuite le DNS). -k si cert staging.
for h in grafana prometheus alertmanager; do
  curl -sk -o /dev/null -w "$h -> %{http_code}\n" \
    --resolve $h.talos.lab.example.io:443:192.168.56.200 https://$h.talos.lab.example.io/
done   # attendu : grafana 302, prometheus 302, alertmanager 200

# Logs qui arrivent dans Loki (labels posés par Alloy) :
kubectl -n monitoring exec deploy/loki-gateway -- \
  wget -qO- http://localhost:8080/loki/api/v1/labels     # app, container, namespace, pod…

🌐 Accès#

Service URL Identifiant Mot de passe
Grafana https://grafana.talos.lab.example.io admin kubectl -n monitoring get secret kube-prometheus-stack-grafana -o jsonpath='{.data.admin-password}' | base64 -d; echo
Prometheus https://prometheus.talos.lab.example.io aucune authentification
Alertmanager https://alertmanager.talos.lab.example.io aucune authentification

🚑 Dépannage#

  • 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 / ReplicaSchedulingFailurelonghorn-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 Talostalosctl logs etcd (il faudrait un shipper dédié pour l'envoyer à Loki).

⚠️ Pièges#

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

    kubectl -n monitoring get cm loki -o jsonpath='{.data.config\.yaml}' \
      | grep -A4 '^compactor:'
    
  • 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 à true après cette install (les CRD ServiceMonitor/PodMonitor n'existent qu'ensuite), puis relancer le *-up.sh de l'addon concerné :

    Fichier Clé à passer à true
    ../trivy-operator/values.yaml serviceMonitor.enabled
    ../cloudnative-pg/values.yaml monitoring.podMonitorEnabled (opérateur)
    ../cloudnative-pg/cluster-demo.yaml monitoring.enablePodMonitor (instances PG)
    ../node-problem-detector/values.yaml metrics.serviceMonitor.enabled
    ../vault-secret-operator/values.yaml telemetry.serviceMonitor.enabled

📚 Références#

Source : _k8s/observability/LISEZ-MOI.md11 sections
_k8s/node-problem-detector/README.md

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

🎯 Purpose#

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.

What it detects (kernel-monitor, via /dev/kmsg)#

Kernel signal Reported as
Killed process … total-vm:… (OOM killer) Event OOMKilling
task … blocked for more than … seconds Event TaskHung
unregister_netdevice: waiting for … Event UnregisterNetDevice
NULL pointer dereference / divide error Event KernelOops
EXT4-fs error/warning, Buffer I/O error, CE memory read error Events Ext4Error / Ext4Warning / IOError / MemoryReadError
Remounting filesystem read-only Condition ReadonlyFilesystem=True
task docker:… blocked for more than … seconds Condition KernelDeadlock=True

NPD keeps these two conditions continuously up to date on every node (KernelDeadlock, ReadonlyFilesystem, False in normal times): that is what you alert on.

Talos adaptation (important)#

  • 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).

⚡ Install#

./_k8s/node-problem-detector/node-problem-detector-up.sh

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.

🔧 What the script does#

  1. creates the node-problem-detector namespace and labels it pod-security.kubernetes.io/{enforce,warn,audit}=privileged;
  2. installs the chart with values.yaml (kernel-monitor only, privileged, tolerations, metrics on :20257) and waits for the DaemonSet.

✅ Verify#

kubectl -n node-problem-detector get pods -o wide          # 1 pod per node (CP + workers), 1/1
# Conditions set by NPD (False = healthy):
kubectl get nodes -o json | jq -r '.items[] | .metadata.name as $n
  | .status.conditions[] | select(.type|test("KernelDeadlock|ReadonlyFilesystem"))
  | "\($n)  \(.type)=\(.status)"'
# Logs of one agent (must load ONLY kernel-monitor, 0 errors):
kubectl -n node-problem-detector logs ds/node-problem-detector | grep -E 'kernel-monitor|Problem detector started'

🧪 Test a detection (optional)#

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
kubectl get events -A --field-selector reason=TaskHung

📈 Metrics#

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.

⚠️ Pitfalls#

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

📚 References#

Source: _k8s/node-problem-detector/README.md10 sections
_k8s/node-problem-detector/LISEZ-MOI.md

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

🎯 À quoi ça sert#

Directement motivé par l'incident cp2 (guest figé) : NPD aurait produit un event TaskHung/OOMKilling visible via kubectl, au lieu d'un simple NotReady opaque.

Ce qu'il détecte (kernel-monitor, via /dev/kmsg)#

Signal noyau Remontée
Killed process … total-vm:… (OOM-killer) Event OOMKilling
task … blocked for more than … seconds Event TaskHung
unregister_netdevice: waiting for … Event UnregisterNetDevice
NULL pointer dereference / divide error Event KernelOops
EXT4-fs error/warning, Buffer I/O error, CE memory read error Events Ext4Error / Ext4Warning / IOError / MemoryReadError
Remounting filesystem read-only Condition ReadonlyFilesystem=True
task docker:… blocked for more than … seconds Condition KernelDeadlock=True

NPD maintient en continu ces deux conditions sur chaque node (KernelDeadlock, ReadonlyFilesystem, à False en temps normal) : c'est dessus qu'on alerte.

Adaptation Talos (important)#

  • 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érations NoSchedule/Exists → NPD tourne aussi sur les control-plane (cp1/2/3).

⚡ Installation#

./_k8s/node-problem-detector/node-problem-detector-up.sh

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.

🔧 Ce que fait le script#

  1. crée le namespace node-problem-detector et le labellise pod-security.kubernetes.io/{enforce,warn,audit}=privileged ;
  2. installe le chart avec values.yaml (kernel-monitor seul, privileged, tolérations, métriques sur :20257) et attend le DaemonSet.

✅ Vérifier#

kubectl -n node-problem-detector get pods -o wide          # 1 pod par node (CP + workers), 1/1
# Conditions posées par NPD (False = sain) :
kubectl get nodes -o json | jq -r '.items[] | .metadata.name as $n
  | .status.conditions[] | select(.type|test("KernelDeadlock|ReadonlyFilesystem"))
  | "\($n)  \(.type)=\(.status)"'
# Logs d'un agent (doit charger UNIQUEMENT kernel-monitor, 0 erreur) :
kubectl -n node-problem-detector logs ds/node-problem-detector | grep -E 'kernel-monitor|Problem detector started'

🧪 Tester une détection (optionnel)#

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
kubectl get events -A --field-selector reason=TaskHung

📈 Métriques#

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.

⚠️ Pièges#

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

📚 Références#

Source : _k8s/node-problem-detector/LISEZ-MOI.md10 sections
_k8s/chaos-kube/README.md

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

🎯 Purpose#

chaoskube picks a pod at random on every tick and deletes it. That single behaviour answers questions no amount of kubectl get will:

Question What a kill reveals
Is this workload actually managed? a bare pod (no Deployment/StatefulSet) never comes back
Does the app tolerate losing a replica? single-replica Deployments = visible downtime
Do the PVCs re-attach? a longhorn RWO volume has to follow the pod to its new node
Is HA real or on paper? ../vault-cluster/ survives losing 1 pod — but comes back sealed, which is why it is excluded

Files in this directory:

File Purpose
chaoskube-up.sh the install: chart, flags, and it prints back the flags actually live in the pod
values.yaml Helm values: interval: 1h, the namespace exclusions, no-dry-run

📋 Prerequisites#

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.

⚡ Install#

Pinned versions: chart 0.6.0, app v0.39.0.

./_k8s/chaos-kube/chaoskube-up.sh

Idempotent (helm upgrade --install), re-runnable. Two knobs:

CHAOS_DRY_RUN=1 ./_k8s/chaos-kube/chaoskube-up.sh   # observe only, deletes nothing
CHAOSKUBE_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.

🔧 Under the hood#

The whole configuration is four flags, from values.yaml:

--interval=1h
--namespaces=!kube-system,!longhorn-system,!vault,!cnpg-demo
--no-dry-run
--timezone=Pacific/Noumea

The exclusion syntax#

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

Dry-run is the upstream default#

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.

✅ Verify#

# dryRun=false is the line that matters — dryRun=true means it is only talking
kubectl -n chaos-kube logs deploy/chaoskube | head -5

# the parsed filters, as chaoskube understood them (not as you wrote them)
kubectl -n chaos-kube logs deploy/chaoskube | grep 'setting pod filter'

# the body count, most recent last
kubectl get events -A --field-selector reason=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.

🌐 Access#

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:

kubectl -n chaos-kube scale deploy/chaoskube --replicas=0

⚠️ Pitfalls#

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

📚 References#

Source: _k8s/chaos-kube/README.md10 sections
_k8s/chaos-kube/LISEZ-MOI.md

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

🎯 À quoi ça sert#

chaoskube tire un pod au hasard à chaque tick et le supprime. Ce seul comportement répond à des questions qu'aucun kubectl get ne tranchera :

Question Ce qu'un kill révèle
Cette charge est-elle vraiment pilotée ? un pod nu (sans Deployment/StatefulSet) ne revient jamais
L'appli tolère-t-elle la perte d'un réplica ? un Deployment à 1 réplica = coupure visible
Les PVC se rattachent-ils ? un volume longhorn RWO doit suivre le pod sur son nouveau node
La HA est-elle réelle ou sur le papier ? ../vault-cluster/ survit à la perte d'1 pod — mais revient scellé, d'où son exclusion

Fichiers du dossier :

Fichier Rôle
chaoskube-up.sh l'install : chart, flags, et il réaffiche les flags réellement actifs dans le pod
values.yaml valeurs Helm : interval: 1h, les exclusions de namespace, no-dry-run

📋 Prérequis#

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.

⚡ Installation#

Versions épinglées : chart 0.6.0, app v0.39.0.

./_k8s/chaos-kube/chaoskube-up.sh

Idempotent (helm upgrade --install), relançable. Deux molettes :

CHAOS_DRY_RUN=1 ./_k8s/chaos-kube/chaoskube-up.sh   # observation seule, ne supprime rien
CHAOSKUBE_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.

🔧 Sous le capot#

Toute la configuration tient en quatre flags, issus de values.yaml :

--interval=1h
--namespaces=!kube-system,!longhorn-system,!vault,!cnpg-demo
--no-dry-run
--timezone=Pacific/Noumea

La syntaxe d'exclusion#

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

Le dry-run est le défaut amont#

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.

✅ Vérifier#

# dryRun=false est LA ligne qui compte — dryRun=true veut dire qu'il ne fait que parler
kubectl -n chaos-kube logs deploy/chaoskube | head -5

# les filtres tels que chaoskube les a COMPRIS (pas tels que tu les as écrits)
kubectl -n chaos-kube logs deploy/chaoskube | grep 'setting pod filter'

# le tableau de chasse, le plus récent en dernier
kubectl get events -A --field-selector reason=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.

🌐 Accès#

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 :

kubectl -n chaos-kube scale deploy/chaoskube --replicas=0

⚠️ Pièges#

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

📚 Références#

Source : _k8s/chaos-kube/LISEZ-MOI.md10 sections
_k8s/kyverno/README.md

⚖️kyverno/ — Kubernetes policy engine + Policy Reporter UI

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.

🎯 Purpose#

Show, on an already-running cluster, what a policy engine catches — without breaking anything:

  • the 4 validation policies ship in Audit (they report, they do not block);
  • every evaluation webhook is fail-open (see 🔧): even with Kyverno down, the cluster keeps accepting pod creations;
  • one mutate policy and one generate policy round out the demo of the three verbs.

Read the existing violations in the UI first, then show a switch to Enforce on a single policy, just for the demo.

📋 Prerequisites#

Prerequisite Why Verify
Platform in place (../platform-up.sh) the UI is exposed through main-gateway + the wildcard cert kubectl -n envoy-gateway-system get certificate
DNS kyverno.talos.lab.example.io → 192.168.56.200 (DNS-only) hostname of the HTTPRoute curl --resolve otherwise (see ✅)
Nothing on the Talos side Kyverno runs unprivileged, compliant with PodSecurity restricted kubectl -n kyverno get pods

⚡ Install#

./_k8s/kyverno/kyverno-up.sh

Versions pinned in the script: chart kyverno/kyverno 3.8.2 (app v1.18.2) and policy-reporter/policy-reporter 3.8.1 (KYVERNO_VERSION / POLICY_REPORTER_VERSION can be overridden). Idempotent (helm upgrade --install + kubectl apply).

🔧 What the script does#

  1. installs Kyverno in the kyverno namespace with values.yaml, then waits for the admission-controller;
  2. applies policies/ (4 validate in Audit + 1 mutate + 1 generate) and lists them;
  3. installs Policy Reporter (+ UI + the kyverno and trivy plugins) with policy-reporter-values.yaml;
  4. applies httproute.yamlhttps://kyverno.talos.lab.example.io.

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:

kubectl get validatingwebhookconfigurations \
  -o 'custom-columns=NAME:.metadata.name,POLICY:.webhooks[*].failurePolicy' | grep kyverno

The quotes around custom-columns=… are required: without them the shell expands [*].

Files#

File Purpose
values.yaml 1 replica per controller (4 controllers), chart default resources, forceFailurePolicyIgnore enabled
policy-reporter-values.yaml Policy Reporter + UI + kyverno plugin + trivy plugin + metrics
httproute.yaml HTTPS HTTPRoute kyverno.talos.lab.example.iopolicy-reporter-ui:8080
kyverno-up.sh installs everything in order, idempotent
policies/01-require-labels.yaml validate/Audit — requires app.kubernetes.io/name
policies/02-disallow-latest-tag.yaml validate/Audit — explicit tag mandatory, :latest forbidden
policies/03-require-requests-limits.yaml validate/Auditrequests + limits (cpu and memory)
policies/04-disallow-privileged.yaml validate/Audit — forbids privileged containers
policies/10-mutate-add-labels.yaml mutate — adds lab.talos/managed-by: kyverno
policies/20-generate-default-netpol.yaml generate — default-deny NetworkPolicy (opt-in by label)

✅ Verify#

kubectl -n kyverno get pods                    # 4 controllers + policy-reporter (+ui, +2 plugins)
kubectl get clusterpolicy                      # 6 policies, READY=True
kubectl get policyreport -A                    # per-namespace reports (PASS/FAIL/WARN)
kubectl get clusterpolicyreport                # cluster-scoped reports

# end-to-end test (trusted wildcard cert, served by Envoy):
curl -sS -o /dev/null -w '%{http_code} verify=%{ssl_verify_result}\n' \
  --resolve kyverno.talos.lab.example.io:443:192.168.56.200 \
  https://kyverno.talos.lab.example.io/            # expected: 200 verify=0

Count the fail results per policy (handy when preparing a demo):

kubectl get policyreport -A -o json | jq -r \
  '.items[].results[] | select(.result=="fail") | .policy' | sort | uniq -c | sort -rn

🌐 Access#

What Where Authentication
Policy Reporter UI https://kyverno.talos.lab.example.io none — see ⚠️ Pitfalls
Policy Reporter API kubectl -n kyverno port-forward svc/policy-reporter 8080:8080 none

🧪 Scenarios#

1. Read the violations (validate in Audit)#

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").

2. Switch a policy to Enforce (real blocking)#

kubectl patch clusterpolicy disallow-latest-tag --type merge \
  -p '{"spec":{"validationFailureAction":"Enforce"}}'
kubectl run bad --image=nginx:latest            # REJECTED by the Kyverno webhook
kubectl patch clusterpolicy disallow-latest-tag --type merge \
  -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.

3. See the mutation in action#

kubectl run demo --image=nginx:1.27
kubectl get pod demo --show-labels              # lab.talos/managed-by=kyverno added
kubectl delete pod demo

4. See generation in action (opt-in by label)#

kubectl create ns demo-netpol
kubectl label ns demo-netpol kyverno-demo=true
kubectl -n demo-netpol get netpol               # default-deny generated by Kyverno
kubectl delete ns demo-netpol

5. Create a targeted exception (PolicyException)#

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.

🚑 Troubleshooting#

  • 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 attachedkubectl -n kyverno describe httproute policy-reporter-ui (sectionName: https on main-gateway, hostname covered by the wildcard).
  • clusterpolicy stuck at READY=Falsekubectl describe clusterpolicy <name>: usually a pattern error, or a CRD missing on the generator side.

⚠️ Pitfalls#

  • "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).

🧹 Uninstall#

kubectl delete -f _k8s/kyverno/httproute.yaml
kubectl delete -f _k8s/kyverno/policies/
helm -n kyverno uninstall policy-reporter
helm -n kyverno uninstall kyverno            # also removes the CRDs → deletes the PolicyReports
kubectl delete ns kyverno

⚠️ If ../trivy-operator/ is installed, it loses its UI: Policy Reporter (namespace kyverno) is what hosts it.

📚 References#

Source: _k8s/kyverno/README.md18 sections
_k8s/kyverno/LISEZ-MOI.md

⚖️kyverno/ — policy engine Kubernetes + UI Policy Reporter

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.

🎯 À quoi ça sert#

Montrer, sur un cluster qui tourne déjà, ce qu'un policy engine attrape — sans rien casser :

  • les 4 policies de validation sont livrées en Audit (elles signalent, ne bloquent pas) ;
  • tous les webhooks d'évaluation sont fail-open (cf. 🔧) : même Kyverno KO, le cluster continue d'accepter les créations de pods ;
  • une policy mutate et une policy generate complètent la démonstration des trois verbes.

On lit d'abord les violations de l'existant dans l'UI, puis on montre un passage en Enforce sur une seule policy, le temps de la démo.

📋 Prérequis#

Prérequis Pourquoi Vérifier
Plateforme en place (../platform-up.sh) l'UI est exposée via main-gateway + cert wildcard kubectl -n envoy-gateway-system get certificate
DNS kyverno.talos.lab.example.io → 192.168.56.200 (DNS-only) hostname de l'HTTPRoute curl --resolve sinon (cf. ✅)
Rien côté Talos Kyverno tourne sans privilège, conforme PodSecurity restricted kubectl -n kyverno get pods

⚡ Installation#

./_k8s/kyverno/kyverno-up.sh

Versions épinglées dans le script : chart kyverno/kyverno 3.8.2 (app v1.18.2) et policy-reporter/policy-reporter 3.8.1 (KYVERNO_VERSION / POLICY_REPORTER_VERSION surchargeables). Idempotent (helm upgrade --install + kubectl apply).

🔧 Ce que fait le script#

  1. installe Kyverno dans le namespace kyverno avec values.yaml, puis attend l'admission-controller ;
  2. applique policies/ (4 validate en Audit + 1 mutate + 1 generate) et les liste ;
  3. installe Policy Reporter (+ UI + plugins kyverno et trivy) avec policy-reporter-values.yaml ;
  4. applique httproute.yamlhttps://kyverno.talos.lab.example.io.

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 :

kubectl get validatingwebhookconfigurations \
  -o 'custom-columns=NAME:.metadata.name,POLICY:.webhooks[*].failurePolicy' | grep kyverno

Les guillemets autour de custom-columns=… sont nécessaires : sinon le shell interprète [*].

Fichiers#

Fichier Rôle
values.yaml 1 replica par contrôleur (4 contrôleurs), resources par défaut du chart, forceFailurePolicyIgnore activé
policy-reporter-values.yaml Policy Reporter + UI + plugin kyverno + plugin trivy + métriques
httproute.yaml HTTPRoute HTTPS kyverno.talos.lab.example.iopolicy-reporter-ui:8080
kyverno-up.sh installe tout dans l'ordre, idempotent
policies/01-require-labels.yaml validate/Audit — exige app.kubernetes.io/name
policies/02-disallow-latest-tag.yaml validate/Audit — tag explicite obligatoire, :latest interdit
policies/03-require-requests-limits.yaml validate/Auditrequests + limits (cpu et memory)
policies/04-disallow-privileged.yaml validate/Audit — interdit les conteneurs privilégiés
policies/10-mutate-add-labels.yaml mutate — ajoute lab.talos/managed-by: kyverno
policies/20-generate-default-netpol.yaml generate — NetworkPolicy default-deny (opt-in par label)

✅ Vérifier#

kubectl -n kyverno get pods                    # 4 contrôleurs + policy-reporter (+ui, +2 plugins)
kubectl get clusterpolicy                      # 6 policies, READY=True
kubectl get policyreport -A                    # rapports par namespace (PASS/FAIL/WARN)
kubectl get clusterpolicyreport                # rapports niveau cluster

# test end-to-end (cert wildcard trusté, servi par Envoy) :
curl -sS -o /dev/null -w '%{http_code} verify=%{ssl_verify_result}\n' \
  --resolve kyverno.talos.lab.example.io:443:192.168.56.200 \
  https://kyverno.talos.lab.example.io/            # attendu : 200 verify=0

Compter les fail par policy (utile pour préparer une démo) :

kubectl get policyreport -A -o json | jq -r \
  '.items[].results[] | select(.result=="fail") | .policy' | sort | uniq -c | sort -rn

🌐 Accès#

Quoi Authentification
UI Policy Reporter https://kyverno.talos.lab.example.io aucune — cf. ⚠️ Pièges
API Policy Reporter kubectl -n kyverno port-forward svc/policy-reporter 8080:8080 aucune

🧪 Scénarios#

1. Lire les violations (validate en Audit)#

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 »).

2. Passer une policy en Enforce (blocage réel)#

kubectl patch clusterpolicy disallow-latest-tag --type merge \
  -p '{"spec":{"validationFailureAction":"Enforce"}}'
kubectl run bad --image=nginx:latest            # REFUSÉ par le webhook Kyverno
kubectl patch clusterpolicy disallow-latest-tag --type merge \
  -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.

3. Voir la mutation en action#

kubectl run demo --image=nginx:1.27
kubectl get pod demo --show-labels              # lab.talos/managed-by=kyverno ajouté
kubectl delete pod demo

4. Voir la génération en action (opt-in par label)#

kubectl create ns demo-netpol
kubectl label ns demo-netpol kyverno-demo=true
kubectl -n demo-netpol get netpol               # default-deny générée par Kyverno
kubectl delete ns demo-netpol

5. Créer une exception ciblée (PolicyException)#

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.

🚑 Dépannage#

  • 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éekubectl -n kyverno describe httproute policy-reporter-ui (sectionName: https sur main-gateway, hostname couvert par le wildcard).
  • clusterpolicy en READY=Falsekubectl describe clusterpolicy <nom> : souvent une erreur de pattern ou une CRD manquante côté générateur.

⚠️ Pièges#

  • « 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).

🧹 Désinstallation#

kubectl delete -f _k8s/kyverno/httproute.yaml
kubectl delete -f _k8s/kyverno/policies/
helm -n kyverno uninstall policy-reporter
helm -n kyverno uninstall kyverno            # retire aussi les CRD → supprime les PolicyReport
kubectl delete ns kyverno

⚠️ Si ../trivy-operator/ est installé, il perd son UI : c'est Policy Reporter (namespace kyverno) qui l'héberge.

📚 Références#

Source : _k8s/kyverno/LISEZ-MOI.md18 sections
_k8s/trivy-operator/README.md

🔎trivy-operator/ — continuous security scanner (Aqua Trivy Operator)

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.

🎯 Purpose#

  • Answer "which CVEs are running here, right now" without a CI pipeline.
  • Complement Kyverno: Kyverno = preventive (blocks/mutates/generates at admission), Trivy = detective (scans what already runs). Both share the Policy Reporter UI.
  • Provide the raw material for a "vulnerability management" module: reports per workload, filter by severity, fixable CVEs only.

What is scanned (and what is not)#

CRD Contents Status in this lab
VulnerabilityReport CVEs in the workload images ✅ active
ConfigAuditReport misconfigurations (Pod Security, best practices) ✅ active
ExposedSecretReport plaintext secrets found in the images ✅ active
RbacAssessmentReport overly permissive RBAC ✅ active
InfraAssessmentReport configuration of the node components disabled (Talos)
ClusterComplianceReport cluster-level CIS / NSA / PSS compliance disabled (Talos)

⚠️ 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-onlyCreateContainerError: 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.

📋 Prerequisites#

Prerequisite Why Verify
../kyverno/ installed it provides Policy Reporter + the UI; without it the script says so and carries on — Trivy runs, but the unified UI has no "trivy" source helm -n kyverno status policy-reporter
Internet access from the nodes every scan job downloads the CVE database kubectl -n trivy-system logs deploy/trivy-operator
Nothing on the Talos side (once the node-collector is off) the scan jobs run unprivileged kubectl -n trivy-system get pods

⚡ Install#

./_k8s/trivy-operator/trivy-operator-up.sh

Versions pinned in the script: chart aqua/trivy-operator 0.34.0 (app v0.32.0) and policy-reporter 3.8.1 (TRIVY_OPERATOR_VERSION / POLICY_REPORTER_VERSION can be overridden). Idempotent.

🔧 What the script does#

  1. installs Trivy Operator in trivy-system with values.yaml, then waits for the rollout;
  2. 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.

The values.yaml settings#

Setting Value Why
operator.scanJobsConcurrentLimit 1 (default: 10) serialized scans: never a CPU/RAM spike on modest VMs
operator.scannerReportTTL 30m an older report gets re-evaluated → re-scan roughly every 30 min, in waves, then idle
operator.infraAssessmentScannerEnabled false node-collector incompatible with Talos (see the callout)
operator.clusterComplianceEnabled false same: compliance aggregates node data
trivy.mode Standalone each job carries its own scan; on a large cluster prefer builtInTrivyServer: true (cached CVE database)
trivy.ignoreUnfixed true shows only fixable CVEs — cuts the noise in a training context
trivy.severity HIGH,CRITICAL focus on what is actionable
serviceMonitor.enabled false the ServiceMonitor CRD does not exist before the observability addon (otherwise the chart fails)

Files#

File Purpose
values.yaml the settings above (serialized scans, node-collector off, less noise)
trivy-operator-up.sh installs Trivy + re-enables Policy Reporter's trivy plugin

✅ Verify#

Scans start on their own; the first reports land within a few minutes (one job at a time).

kubectl -n trivy-system get pods                  # trivy-operator Running (+ ephemeral scan-* jobs)
kubectl get vulnerabilityreports -A               # CVEs per workload
kubectl get configauditreports -A                 # config audits
kubectl get exposedsecretreports -A               # exposed secrets
kubectl get rbacassessmentreports -A              # overly permissive RBAC
kubectl -n kyverno get pods | grep trivy-plugin   # policy-reporter-trivy-plugin Running
# unified UI (Kyverno + Trivy): https://kyverno.talos.lab.example.io → "trivy" source

Top of the most vulnerable images:

kubectl get vulnerabilityreports -A -o json | jq -r \
  '.items[] | "\(.report.summary.criticalCount + .report.summary.highCount)\t\(.metadata.namespace)/\(.metadata.name)"' \
  | sort -rn | head

🧪 Scenarios#

1. Find the lab's vulnerable images#

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

3. Read a ConfigAuditReport as a PSS audit#

With no ClusterComplianceReport (see the callout), ConfigAuditReport objects remain the best entry point: they carry the Pod Security-style checks on every workload.

kubectl -n kyverno get configauditreports -o json | jq -r \
  '.items[0].report.checks[] | select(.success==false) | "\(.severity)\t\(.checkID)\t\(.title)"'

⚠️ 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/.

⚠️ Pitfalls#

  • 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 minkubectl -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 noisetrivy.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.

📚 References#

Source: _k8s/trivy-operator/README.md15 sections
_k8s/trivy-operator/LISEZ-MOI.md

🔎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é.

🎯 À quoi ça sert#

  • Répondre à « quelles CVE tournent chez moi, maintenant » sans pipeline CI.
  • Compléter Kyverno : Kyverno = préventif (bloque/mute/génère à l'admission), Trivy = détectif (scanne l'existant). Les deux partagent l'UI Policy Reporter.
  • Fournir la matière d'un module « gestion des vulnérabilités » : rapports par workload, filtre par sévérité, CVE corrigeables uniquement.

Ce qui est scanné (et ce qui ne l'est pas)#

CRD Contenu État dans ce lab
VulnerabilityReport CVE des images des workloads ✅ actif
ConfigAuditReport mauvaises configs (Pod Security, bonnes pratiques) ✅ actif
ExposedSecretReport secrets en clair trouvés dans les images ✅ actif
RbacAssessmentReport RBAC trop permissif ✅ actif
InfraAssessmentReport configuration des composants du node désactivé (Talos)
ClusterComplianceReport conformité CIS / NSA / PSS au niveau cluster désactivé (Talos)

⚠️ 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 seuleCreateContainerError: 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.

📋 Prérequis#

Prérequis Pourquoi Vérifier
../kyverno/ installé il fournit Policy Reporter + l'UI ; sans lui le script le signale et continue — Trivy tourne, mais l'UI unifiée n'a pas la source « trivy » helm -n kyverno status policy-reporter
Accès Internet depuis les nodes chaque job de scan télécharge la base de CVE kubectl -n trivy-system logs deploy/trivy-operator
Rien côté Talos (une fois le node-collector coupé) les jobs de scan tournent sans privilège kubectl -n trivy-system get pods

⚡ Installation#

./_k8s/trivy-operator/trivy-operator-up.sh

Versions épinglées dans le script : chart aqua/trivy-operator 0.34.0 (app v0.32.0) et policy-reporter 3.8.1 (TRIVY_OPERATOR_VERSION / POLICY_REPORTER_VERSION surchargeables). Idempotent.

🔧 Ce que fait le script#

  1. installe Trivy Operator dans trivy-system avec values.yaml, puis attend le rollout ;
  2. 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.

Les réglages de values.yaml#

Réglage Valeur Pourquoi
operator.scanJobsConcurrentLimit 1 (défaut : 10) scans sérialisés : jamais de pic CPU/RAM sur des VM modestes
operator.scannerReportTTL 30m un rapport plus vieux est ré-évalué → re-scan ~toutes les 30 min, par vagues, puis repos
operator.infraAssessmentScannerEnabled false node-collector incompatible Talos (voir l'encart)
operator.clusterComplianceEnabled false idem : la conformité agrège des données de node
trivy.mode Standalone chaque job embarque son scan ; pour un gros cluster préférer builtInTrivyServer: true (base CVE en cache)
trivy.ignoreUnfixed true n'affiche que les CVE corrigeables — réduit le bruit en formation
trivy.severity HIGH,CRITICAL on se concentre sur l'actionnable
serviceMonitor.enabled false le CRD ServiceMonitor n'existe pas avant l'addon observability (sinon le chart échoue)

Fichiers#

Fichier Rôle
values.yaml les réglages ci-dessus (scans sérialisés, node-collector coupé, bruit réduit)
trivy-operator-up.sh installe Trivy + réactive le plugin trivy de Policy Reporter

✅ Vérifier#

Les scans démarrent seuls ; les premiers rapports arrivent en quelques minutes (un job à la fois).

kubectl -n trivy-system get pods                  # trivy-operator Running (+ jobs scan-* éphémères)
kubectl get vulnerabilityreports -A               # CVE par workload
kubectl get configauditreports -A                 # audits de config
kubectl get exposedsecretreports -A               # secrets exposés
kubectl get rbacassessmentreports -A              # RBAC trop permissif
kubectl -n kyverno get pods | grep trivy-plugin   # policy-reporter-trivy-plugin Running
# UI unifiée (Kyverno + Trivy) : https://kyverno.talos.lab.example.io → source « trivy »

Top des images les plus vulnérables :

kubectl get vulnerabilityreports -A -o json | jq -r \
  '.items[] | "\(.report.summary.criticalCount + .report.summary.highCount)\t\(.metadata.namespace)/\(.metadata.name)"' \
  | sort -rn | head

🧪 Scénarios#

1. Trouver les images vulnérables du lab#

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.

2. Boucler préventif + détectif (Kyverno × Trivy)#

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

3. Lire un ConfigAuditReport comme un audit PSS#

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.

kubectl -n kyverno get configauditreports -o json | jq -r \
  '.items[0].report.checks[] | select(.success==false) | "\(.severity)\t\(.checkID)\t\(.title)"'

⚠️ 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 status n'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.

📈 Intégration Prometheus (après l'addon observability)#

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

⚠️ Pièges#

  • 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 minkubectl -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 importanttrivy.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.

📚 Références#

Source : _k8s/trivy-operator/LISEZ-MOI.md15 sections
_k8s/argocd/README.md

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

🎯 Purpose#

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

The setup in one sentence#

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.

📋 Prerequisites#

Prerequisite Why Verify
main-gateway with the https:443 listener (../envoy-gateway/) carries the UI over HTTPS kubectl get gateway -n envoy-gateway-system
Wildcard cert wildcard-talos-lab-example-io-tls READY=True (../cert-manager/) otherwise TLS is untrusted kubectl -n envoy-gateway-system get certificate
DNS argo.talos.lab.example.io → 192.168.56.200 in DNS-only hostname of the HTTPRoute curl --resolve otherwise (see ✅)
Nothing on the Talos side Argo CD needs no privilege and no hostPath kubectl -n argocd get pods

⚡ Install#

./_k8s/argocd/argocd-up.sh

Chart argo/argo-cd 10.2.1 (app v3.4.5), pinned in the script via ARGOCD_VERSION (can be overridden). Idempotent (helm upgrade --install + kubectl apply).

Manual equivalent
helm repo add argo https://argoproj.github.io/argo-helm && helm repo update
# --version: keep the one from the script (ARGOCD_VERSION)
helm upgrade --install argocd argo/argo-cd \
  --namespace argocd --create-namespace \
  --version 10.2.1 \
  --values _k8s/argocd/values.yaml
kubectl -n argocd rollout status deploy/argocd-server
kubectl apply -f _k8s/argocd/httproute.yaml

🔧 What the script does#

  1. installs the chart in the argocd namespace with values.yaml, then waits for deploy/argocd-server (300 s max);
  2. applies httproute.yaml;
  3. prints the URL, the initial-password command and the test curl.

Files#

File Purpose
values.yaml global.domain + server.insecure: true + public url; Dex and notifications turned off (slimming down); ApplicationSet left enabled
httproute.yaml HTTPS HTTPRoute argo.talos.lab.example.ioargocd-server:80, sectionName: https
argocd-up.sh installs Argo CD + applies the route (idempotent)

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.

✅ Verify#

kubectl -n argocd get pods                            # server/repo-server/redis/app-controller Running
kubectl -n argocd get httproute argocd-server         # Accepted + ResolvedRefs = True
# end-to-end test (trusted wildcard cert, served by Envoy):
curl -sS -o /dev/null -w '%{http_code} verify=%{ssl_verify_result}\n' \
  --resolve argo.talos.lab.example.io:443:192.168.56.200 \
  https://argo.talos.lab.example.io/                      # expected: 200 verify=0

--resolve bypasses DNS: handy for testing before you create the Cloudflare record.

🌐 Access#

What Value
UI https://argo.talos.lab.example.io
User admin
Initial password kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d ; echo
CLI argocd login argo.talos.lab.example.io --grpc-web --username admin

💡 Change the password from the UI, then delete the initial Secret: kubectl -n argocd delete secret argocd-initial-admin-secret.

--grpc-web is required: native gRPC is often broken by L7 proxies; here the API goes through the same HTTPS host as the UI.

🧪 Scenario — your first Application#

kubectl apply -f - <<'EOF'
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: guestbook
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/argoproj/argocd-example-apps.git
    targetRevision: HEAD
    path: guestbook
  destination:
    server: https://kubernetes.default.svc
    namespace: guestbook
  syncPolicy:
    automated: { prune: true, selfHeal: true }
    syncOptions: [CreateNamespace=true]
EOF
kubectl -n argocd get applications                    # SYNC STATUS / HEALTH STATUS
kubectl -n guestbook get pods
# Self-heal demo: delete an object by hand, Argo CD recreates it
kubectl -n guestbook delete deploy --all
kubectl -n argocd delete application guestbook        # cleanup (prune=true deletes the objects)

🚑 Troubleshooting#

  • Redirect loop / too many redirectsserver.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 attachedkubectl -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.

⚠️ Pitfalls#

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

📚 References#

Source: _k8s/argocd/README.md12 sections
_k8s/argocd/LISEZ-MOI.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.

🎯 À quoi ça sert#

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

Le montage en une phrase#

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.

📋 Prérequis#

Prérequis Pourquoi Vérifier
main-gateway avec l'écouteur https:443 (../envoy-gateway/) porte l'UI en HTTPS kubectl get gateway -n envoy-gateway-system
Cert wildcard wildcard-talos-lab-example-io-tls READY=True (../cert-manager/) sinon TLS non trusté kubectl -n envoy-gateway-system get certificate
DNS argo.talos.lab.example.io → 192.168.56.200 en DNS-only hostname de l'HTTPRoute curl --resolve sinon (cf. ✅)
Rien côté Talos Argo CD n'a besoin d'aucun privilège ni hostPath kubectl -n argocd get pods

⚡ Installation#

./_k8s/argocd/argocd-up.sh

Chart argo/argo-cd 10.2.1 (app v3.4.5), épinglé dans le script via ARGOCD_VERSION (surchargeable). Idempotent (helm upgrade --install + kubectl apply).

Équivalent manuel
helm repo add argo https://argoproj.github.io/argo-helm && helm repo update
# --version : garder celle du script (ARGOCD_VERSION)
helm upgrade --install argocd argo/argo-cd \
  --namespace argocd --create-namespace \
  --version 10.2.1 \
  --values _k8s/argocd/values.yaml
kubectl -n argocd rollout status deploy/argocd-server
kubectl apply -f _k8s/argocd/httproute.yaml

🔧 Ce que fait le script#

  1. installe le chart dans le namespace argocd avec values.yaml, puis attend deploy/argocd-server (300 s max) ;
  2. applique httproute.yaml ;
  3. rappelle l'URL, la commande du mot de passe initial et le curl de test.

Fichiers#

Fichier Rôle
values.yaml global.domain + server.insecure: true + url publique ; Dex et notifications coupés (allègement) ; ApplicationSet laissé actif
httproute.yaml HTTPRoute HTTPS argo.talos.lab.example.ioargocd-server:80, sectionName: https
argocd-up.sh installe Argo CD + applique la route (idempotent)

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.

✅ Vérifier#

kubectl -n argocd get pods                            # server/repo-server/redis/app-controller Running
kubectl -n argocd get httproute argocd-server         # Accepted + ResolvedRefs = True
# test end-to-end (cert wildcard trusté, servi par Envoy) :
curl -sS -o /dev/null -w '%{http_code} verify=%{ssl_verify_result}\n' \
  --resolve argo.talos.lab.example.io:443:192.168.56.200 \
  https://argo.talos.lab.example.io/                      # attendu : 200 verify=0

--resolve court-circuite le DNS : pratique pour tester avant de créer l'enregistrement Cloudflare.

🌐 Accès#

Quoi Valeur
UI https://argo.talos.lab.example.io
Utilisateur admin
Mot de passe initial kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d ; echo
CLI argocd login argo.talos.lab.example.io --grpc-web --username admin

💡 Change le mot de passe depuis l'UI, puis supprime le Secret initial : kubectl -n argocd delete secret argocd-initial-admin-secret.

--grpc-web est requis : le gRPC natif est souvent cassé par les proxies L7 ; ici l'API passe par le même hôte HTTPS que l'UI.

🧪 Scénario — première Application#

kubectl apply -f - <<'EOF'
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: guestbook
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/argoproj/argocd-example-apps.git
    targetRevision: HEAD
    path: guestbook
  destination:
    server: https://kubernetes.default.svc
    namespace: guestbook
  syncPolicy:
    automated: { prune: true, selfHeal: true }
    syncOptions: [CreateNamespace=true]
EOF
kubectl -n argocd get applications                    # SYNC STATUS / HEALTH STATUS
kubectl -n guestbook get pods
# Démo du self-heal : supprime un objet à la main, Argo CD le recrée
kubectl -n guestbook delete deploy --all
kubectl -n argocd delete application guestbook        # nettoyage (prune=true supprime les objets)

🚑 Dépannage#

  • Boucle de redirection / too many redirectsserver.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éekubectl -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).
  • UI OK mais argocd login KO → ajouter --grpc-web.

⚠️ Pièges#

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

📚 Références#

Source : _k8s/argocd/LISEZ-MOI.md12 sections
_k8s/wordpress-example/README.md

📝wordpress-example/ — WordPress + MariaDB (persistent storage demo)

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.

🎯 Purpose#

  • Prove that a PVC survives a pod restart, and show it with a real application.
  • Illustrate three block-storage classics: RWO / single-attach, strategy: Recreate, and subPath to dodge lost+found.
  • Illustrate offloaded TLS termination: an app that has to be told it sits behind an HTTPS proxy.

📋 Prerequisites#

Prerequisite Why Verify
StorageClass longhorn (../longhorn/) both PVCs name it explicitly kubectl get sc longhorn
main-gateway + https:443 listener (../envoy-gateway/) carries the HTTPRoute kubectl get gateway -n envoy-gateway-system
Wildcard cert READY=True (../cert-manager/) trusted HTTPS kubectl -n envoy-gateway-system get certificate
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 apply succeeds and the PVCs stay silently Pending, with the pods stuck in Pending too. Check kubectl get sc first. 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/).

⚡ Install#

kubectl apply -f _k8s/wordpress-example/wordpress-mariadb.yaml

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:

sed 's/talos\.lab\.example\.io/talos.lab.my-domain.tld/g' \
  _k8s/wordpress-example/wordpress-mariadb.yaml | kubectl apply -f -

(see ../README.md).

Three places to cover in this manifest: the hostname of the HTTPRoute and WP_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.

🔧 Contents of wordpress-mariadb.yaml#

Object Purpose
Namespace wordpress-test isolates the demo
Secret mariadb DB credentials — example passwords in plaintext in the manifest (see ⚠️ Pitfalls)
PVC mariadb-data / wordpress-data 2Gi Longhorn each, ReadWriteOnce
Deployment mariadb (mariadb:11.4) DB, strategy: Recreate, volume mounted with subPath: mysql, healthcheck.sh probes
Deployment wordpress (wordpress:6.7-php8.3-apache) front end, Recreate, subPath: wp, probe on /wp-login.php
Service mariadb / wordpress ClusterIP (3306 / 80)
HTTPRoute wordpress wordpress.talos.lab.example.iowordpress:80, sectionName: https of main-gateway

Both containers declare requests (cpu + memory) and a memory limit only — no CPU limit (repo choice: cap RAM, do not throttle CPU).

The three points that make the demo#

  • 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 freeze WP_HOME/WP_SITEURL to https://… in WORDPRESS_CONFIG_EXTRA; otherwise WordPress generates http URLs and loops on redirects.

✅ Verify#

kubectl -n wordpress-test get pvc,pods            # PVC Bound, pods Running 1/1
curl -sS -o /dev/null -w '%{http_code}\n' \
  --resolve wordpress.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/

🧪 Scenario — persistence, live#

# 1. Finish the WordPress install in the browser, publish a post.
# 2. Kill both pods: their PVCs are reattached on restart.
kubectl -n wordpress-test delete pod --all
kubectl -n wordpress-test get pods -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.

⚠️ Pitfalls#

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

🧹 Cleanup#

kubectl delete -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.

📚 References#

Source: _k8s/wordpress-example/README.md10 sections
_k8s/wordpress-example/LISEZ-MOI.md

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

🎯 À quoi ça sert#

  • Prouver qu'un PVC survit au redémarrage d'un pod, et le montrer avec une vraie appli.
  • Illustrer trois classiques du stockage bloc : RWO / mono-attach, strategy: Recreate, et le subPath pour éviter le lost+found.
  • Illustrer la terminaison TLS déportée : une appli qui doit être prévenue qu'elle est derrière un proxy HTTPS.

📋 Prérequis#

Prérequis Pourquoi Vérifier
StorageClass longhorn (../longhorn/) les 2 PVC la nomment explicitement kubectl get sc longhorn
main-gateway + écouteur https:443 (../envoy-gateway/) porte l'HTTPRoute kubectl get gateway -n envoy-gateway-system
Cert wildcard READY=True (../cert-manager/) HTTPS trusté kubectl -n envoy-gateway-system get certificate
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 apply réussit et les PVC restent silencieusement Pending, pods bloqués en Pending eux aussi. Vérifie kubectl get sc avant. 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/).

⚡ Installation#

kubectl apply -f _k8s/wordpress-example/wordpress-mariadb.yaml

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 :

sed 's/talos\.lab\.example\.io/talos.lab.mon-domaine.tld/g' \
  _k8s/wordpress-example/wordpress-mariadb.yaml | kubectl apply -f -

(cf. ../LISEZ-MOI.md).

Trois endroits à couvrir dans ce manifeste : le hostname de l'HTTPRoute et WP_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.

🔧 Contenu de wordpress-mariadb.yaml#

Objet Rôle
Namespace wordpress-test isole la démo
Secret mariadb identifiants DB — mots de passe d'exemple en clair dans le manifeste (cf. ⚠️ Pièges)
PVC mariadb-data / wordpress-data 2Gi Longhorn chacun, ReadWriteOnce
Deployment mariadb (mariadb:11.4) DB, strategy: Recreate, volume monté en subPath: mysql, sondes healthcheck.sh
Deployment wordpress (wordpress:6.7-php8.3-apache) front, Recreate, subPath: wp, sonde sur /wp-login.php
Service mariadb / wordpress ClusterIP (3306 / 80)
HTTPRoute wordpress wordpress.talos.lab.example.iowordpress: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).

Les trois points qui font la démo#

  • 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 fige WP_HOME/WP_SITEURL en https://… dans WORDPRESS_CONFIG_EXTRA ; sinon WordPress génère des URLs en http et boucle en redirection.

✅ Vérifier#

kubectl -n wordpress-test get pvc,pods            # PVC Bound, pods Running 1/1
curl -sS -o /dev/null -w '%{http_code}\n' \
  --resolve wordpress.talos.lab.example.io:443:192.168.56.200 \
  https://wordpress.talos.lab.example.io/             # 302 → /wp-admin/install.php (WP frais)
# puis finir l'installation dans le navigateur : https://wordpress.talos.lab.example.io/

🧪 Scénario — la persistance, en direct#

# 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 -n wordpress-test delete pod --all
kubectl -n wordpress-test get pods -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.

⚠️ Pièges#

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

🧹 Nettoyer#

kubectl delete -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.

📚 Références#

Source : _k8s/wordpress-example/LISEZ-MOI.md10 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.

🚀 Order of work#

  1. vagrant up creates/starts the VMs (Talos boots off the ISO in maintenance mode).
  2. ./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).
  3. ./_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.

🚧 Working rules (non-negotiable)#

  • "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)#

make validate      # bash -n on every script + YAML parse + vagrant validate + config gen
make docs          # 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.

⚠️ Pitfalls (already hit — do not repeat)#

  • 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>.yaml and --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-demovault 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 no extraMounts — 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.mdLISEZ-MOI.md, UPGRADE.mdMISE-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.

🔐 Secrets#

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

📝 Conventions#

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

🚀 Order of work#

  1. vagrant up creates/starts the VMs (Talos boots off the ISO in maintenance mode).
  2. ./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).
  3. ./_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.

🚧 Working rules (non-negotiable)#

  • "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)#

make validate      # bash -n on every script + YAML parse + vagrant validate + config gen
make docs          # 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.

⚠️ Pitfalls (already hit — do not repeat)#

  • 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>.yaml and --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-demovault 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 no extraMounts — 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.mdLISEZ-MOI.md, UPGRADE.mdMISE-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.

🔐 Secrets#

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

📝 Conventions#

  • 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