k8s-playground

☸️k8s-playground — the Kubernetes layer for the Talos and kubeadm labs

One repository of manifests and charts for two Vagrant labs. The application layer (CNI, Gateway API, storage, secrets, observability, security) used to be duplicated, in Vagrant-Talos/_k8s/ and again in Vagrant-KubeADM/_k8s/. It now lives here once — and both labs mount this repository at that very same _k8s/ path, as a git submodule. Mounted that way, the lab and its distribution are found on their own — no argument, no environment variable, no lab.env here:

./_k8s/platform-up.sh                # from either lab's root      (submodule layout)
./_k8s/install.sh platform           # same thing, via the entry point
./install.sh kubeadm platform        # from this repo's root       (sibling layout)

📖 Browsable documentation: https://ops-nc.github.io/k8s-playground/ — a single self-contained page, bilingual (EN/FR) with a dark/light theme, rebuilt from these very README.md / LISEZ-MOI.md files on every push to main (make docs to build it locally).

Both labs remain responsible for bootstrapping the cluster (VMs, OS, kubeadm init / talosctl bootstrap). This repository only covers what comes after, with kubectl and helm from the host — including the CNI, because neither bootstrap leaves a usable pod network behind.

⚡ Quick start#

You never start here: you start in a lab, which is where the cluster and its state live. This repository is mounted there as the _k8s/ submodule.

# 1. The lab — its submodule included (Talos twin: OPS-NC/Vagrant-Talos)
git clone --recurse-submodules https://github.com/OPS-NC/Vagrant-kubeadm.git
cd Vagrant-kubeadm
cp lab.env.example lab.env               # the LAB's model: topology, domain, TLS mode, CNI
vagrant up && ./kubeadm/cluster-up.sh    # nodes are NotReady: no CNI yet

# 2. Lay down the base platform — nothing to declare
./_k8s/platform-up.sh                # CNI → Envoy Gateway → metrics-server → wildcard TLS

# 3. Add-ons, opt-in
./_k8s/install.sh longhorn vault argocd
./_k8s/install.sh list               # the full catalogue
./_k8s/install.sh all                # platform + every add-on, in dependency order

💡 Nothing to declare, and that is the whole point. From the lab root, this repository is <lab>/_k8s: the lab is its parent, recognised by its Vagrantfile, and the distribution is read off that lab's own structure — kubeadm/cluster-up.sh here, talos/cluster-up.sh in the Talos twin. LAB_DIR is no longer needed, and neither is the talos/kubeadm argument. KUBECONFIG is derived the same way (<lab>/kubeconfig); export it yourself only for your own kubectl calls, not for the scripts.

Passing the distribution explicitly still works, and still wins — useful to be sure of what you are running, or in the sibling layout below:

./_k8s/install.sh kubeadm platform
Variant — the two repositories side by side (the pre-submodule layout, still supported)
cd ../Vagrant-Talos    && ./talos/cluster-up.sh        # or
cd ../Vagrant-KubeADM  && ./kubeadm/cluster-up.sh

cd ../k8s-playground                 # this repo, sibling of the lab
./install.sh talos platform          # one lab next door ⇒ the argument is optional here too

Here the lab repository sits next to this one, not above it, so the parent-Vagrantfile rule does not fire: the lab directory is found through LAB_REPO_NAME (../Vagrant-Talos, ../Vagrant-KubeADM), which the distribution profile supplies, and the distribution itself is read off those same two candidate directories, tested by name. It follows that with both labs cloned side by side the choice is ambiguous — the scripts then refuse to guess and you pass talos/kubeadm. That is the only difference between the two layouts.

Every component is still runnable on its own, bare in the submodule layout:

./_k8s/longhorn/longhorn-up.sh               # from the lab root    (submodule layout)
./observability/observability-up.sh talos    # from this repo root  (sibling layout)

🎓 Training mode. Every directory ships a README.md (EN) / LISEZ-MOI.md (FR) with a "Guided walkthrough" section: the same installation, command by command, with what to observe at each step and the per-distribution variants. The all-in-one script and the walkthrough do exactly the same thing.

🧩 Two layouts: submodule or sibling repositories#

Both labs mount this repository as a git submodule on _k8s/, at their root. That is the normal layout, the one every lab command assumes. Keeping the two repositories side by side — what existed before — still works, and stays documented here as the variant.

Submodule (recommended) Sibling repositories (variant)
On disk Vagrant-KubeADM/_k8s/ is this repo Vagrant-KubeADM/ and k8s-playground/ in the same parent directory
Where you run the scripts from the lab root: ./_k8s/install.sh … from this repo's root: ./install.sh <distro> …
LAB_DIR not needed: the parent carries a Vagrantfile ⇒ rule 2 below not needed either: rule 3 below finds ../<lab repo> on its own
Distribution argument not needed: read off the lab's structure not needed with a single lab next door (../Vagrant-Talos / ../Vagrant-KubeADM are probed directly); needed if both are there, or if the directory was renamed
Version of the application layer pinned by the lab to one commit ⇒ the lab is reproducible whatever happens to be checked out next door
Getting it git clone --recurse-submodules …, or git submodule update --init --recursive one git clone per repository
Updating it git submodule update --remote _k8s, then commit the moved pointer git pull here
Naming constraint none: _k8s/ is imposed by the labs, and both use it the lab directory must be named exactly Vagrant-Talos / Vagrant-KubeADM (LAB_REPO_NAME), otherwise LAB_DIR again

Installing and updating the submodule, from the lab:

git clone --recurse-submodules https://github.com/OPS-NC/Vagrant-kubeadm.git
git submodule update --init --recursive   # fills _k8s/ on a clone already made
git submodule update --remote _k8s        # moves _k8s/ onto the latest commit of this repo

⚠️ git pull in the lab does NOT update the submodule. It moves the lab repository only; _k8s/ stays on the commit pinned before, and you would be running the documented commands against an older application layer. Follow every pull with git submodule update --init --recursive. And an empty _k8s/./_k8s/install.sh: No such file or directory — always means one single thing: the submodule was never initialised.

💡 Changing this layer is a change made here. Seen from a lab, _k8s/ is a checkout of this repository on a detached HEAD: files edited in it belong to this repo, not to the lab, and a git commit run inside _k8s/ commits here. The route is therefore: PR on this repository → merge → in the lab, git submodule update --remote _k8s, then commit the bumped pointer (git status shows modified: _k8s (new commits)). That commit is the application layer's version bump, and it is what keeps a lab reproducible.

📍 Where lab.env and _out/ are found#

The scripts store nothing. lab.env (the intent: domain, TLS mode, CNI, VM sizes) and _out/ (the facts: talosconfig, cluster.env, the local CA in _out/self-signed/, _out/vault-init.json…) live in the lab repository, with the kubeconfig right beside them at its root. There is exactly one source of truth for the topology, and it is the lab's — which is why this repository ships no lab.env and no model for one. _resolve_lab_dir(), in lib/common.sh, looks for that directory in this order:

# Candidate Applies when
1 $LAB_DIR, failing that the directory holding $LAB_ENV either one is exported — explicit override, always wins
2 the parent directory of this repo, if it holds a Vagrantfile submodule layout: this repo is <lab>/_k8s, so the lab is simply one level up
3 <root of this repo>/../$LAB_REPO_NAMEVagrant-Talos or Vagrant-KubeADM, set by the distribution profile that directory exists ⇒ sibling layout (only once the profile is loaded)
4 the root of this repository a lab.env file or an _out/ directory sits here (a symlink counts) — standalone use
5 fallback: the root of this repository nothing above matched

KUBECONFIG follows the same directory: unless it is already exported, it becomes <lab dir>/kubeconfig.

ℹ️ Why the test is Vagrantfile, and why it comes before rule 4. A Vagrantfile is the one unambiguous mark of a lab: it is there from the clone, before any vagrant up, and it never appears above this repo in the sibling layout — where the parent is just some working directory. Ordering it before the "root of this repo" rule is deliberate: a leftover _out/ (or a lab.env dropped here for a one-off test) must never shadow the real lab sitting right above. Explicit LAB_DIR/LAB_ENV stays above everything, because an override that can be outvoted is not an override.

⚠️ This is exactly the failure the parent rule removes. Before it, the submodule layout resolved <root>/../Vagrant-KubeADM to Vagrant-KubeADM/Vagrant-KubeADM — a path that does not exist — and fell through to this repository itself, which carries neither lab.env nor _out/. Nothing failed loudly: the scripts ran on the profile defaults<distro>.lab.example.io instead of your LAB_DOMAIN (hence a wildcard TLS Secret under a name no add-on would look for), CNI=cilium instead of the CNI you picked, guessed POD_CIDR / HOSTONLY_IF / KUBE_PROXY_REPLACEMENT instead of the ones cluster-up.sh detected, and a KUBECONFIG pointing at a file that does not exist. If you ever see that shape of symptom again, it is a resolution problem: read the summary line first.

💡 One signal does fire, on kubeadm only. platform-up.sh warns when _out/cluster.env is missing — "./kubeadm/cluster-up.sh has not been run (or not to the end)". Two readings: either the bootstrap really did not finish, or the lab was not the one resolved. The warning is non-blocking, and on Talos there is no equivalent (that lab has no cluster.env).

Every script prints its resolution before touching anything — one line, worth reading:

    profil kubeadm (Debian 13 + kubeadm) · domaine *.kubeadm.lab.example.io · lab.env absent (défauts)

lab.env absent (défauts) on a lab that does have a lab.env, or a domaine that is not the one you set: the resolution missed the lab — force it with LAB_DIR.

Overriding it all by hand stays possible — useful for a lab kept somewhere else entirely, or to exercise this repository in isolation:

LAB_DIR=~/labs/my-lab   ./install.sh talos platform      # lab.env + _out/ + kubeconfig, all there
LAB_ENV=~/labs/my-lab/lab.env  ./install.sh talos platform   # its directory becomes the lab dir
LAB_ENV=~/labs/my-lab/lab.env  KUBECONFIG=~/labs/my-lab/kubeconfig  ./install.sh talos platform

⚠️ Do not create a lab.env at this repo's root. Rule 4 accepts one, and it is there for exercising this repo without any lab — but a second lab.env is a second truth, drifting from the lab's the moment either changes. That is precisely why there is no lab.env.example here: the model to copy lives in the lab.

🎯 How the distribution is selected#

In priority order:

# Source Example
1 first positional argument ./install.sh talos longhorn · ./longhorn/longhorn-up.sh talos
2 --distro= ./platform-up.sh --distro=kubeadm
3 environment variable K8S_DISTRO=talos ./install.sh longhorn
4 DISTRO= in the lab's lab.env DISTRO=kubeadm
5 the lab's structure talos/cluster-up.shtalos · kubeadm/cluster-up.shkubeadm
6 the lab's bootstrap artefacts _out/talosconfigtalos · _out/cluster.envkubeadm
7 the neighbouring lab repository ../Vagrant-Talos/talos/cluster-up.shtalos · ../Vagrant-KubeADM/kubeadm/cluster-up.shkubeadm
8 probing the cluster first node's osImage: Talos …talos, anything else → kubeadm

Sources 5 to 8 are _detect_distro(), four families of signals ordered by how early they become available:

Signal Available from Cost
structure — the bootstrap script of the distribution the lab implements the git clone, before any vagrant up none: a file test
artefacts — what the bootstrap wrote into _out/ after cluster-up.sh none: a file test. Covers a lab whose directories were renamed
neighbours — the same test run on ../Vagrant-Talos and ../Vagrant-KubeADM the lab's git clone none, but refuses to decide when both are there (see below)
probekubectl get nodes -o jsonpath=…osImage a cluster that is up and a KUBECONFIG that already points at it last resort only

ℹ️ Source 7 hard-codes the two names, on purpose. LAB_REPO_NAME would have been the natural way to name the neighbour, but the profile sets it — and the profile is what we are trying to choose. So the two candidates are tested directly. If both are present next door, nothing is decided and the scripts ask: guessing there would mean a one-in-two chance of deploying onto the wrong cluster. Pass talos/kubeadm in that case.

ℹ️ Why the probe is last. It needs a KUBECONFIG that is already correct — but KUBECONFIG is derived from the lab directory, which comes from the profile, which is loaded only once the distribution is known. By construction the probe is the least available signal at the exact moment it is needed, so it only ever answers for an ambient kubectl context. Sources 5 and 6 read the lab directly and have no such dependency.

With none of the above, the scripts refuse to run: applying a Talos-shaped manifest on Debian (or the reverse) does not produce a clean error but a silent failure — a Deployment that gets created while no pod ever starts, for instance.

ℹ️ Sources 4 to 6 need the lab to be located first — which is where layout matters. In submodule layout the parent-Vagrantfile rule fires before any profile is loaded, so all three work bare: that is what makes ./_k8s/platform-up.sh self-sufficient. In sibling layout no lab is located at that point (LAB_REPO_NAME comes from the profile), so those three stay silent and source 7 takes over — which is why ./install.sh platform works bare from here too, as long as a single lab sits next door. Two labs side by side, or a renamed directory: pass the distribution, or export LAB_DIR. An explicit argument short-circuits the whole question and stays the surest way to know what you ran.

🧬 What actually differs between the two labs#

Everything is concentrated in lib/profiles/talos.sh and lib/profiles/kubeadm.sh: the install scripts never test the distribution through scattered ifs, they read variables.

Topic Talos Linux Debian 13 + kubeadm Profile variable
Default UI domain talos.lab.example.io kubeadm.lab.example.io DEFAULT_LAB_DOMAIN
PodSecurity (cluster level) baseline enforced → a privileged pod needs a namespace labelled privileged, otherwise it fails silently no level enforced → the same labels unblock nothing, they document intent PODSECURITY_DEFAULT
Filesystem immutable: / and /usr read-only, only /var is writable ordinary, everything is writable
local-path-provisioner /var/local-path-provisioner /opt/local-path-provisioner (upstream path) LOCAL_PATH_DIR
iSCSI prerequisite (Longhorn) an extension (iscsi-tools) baked into the installer image (not fixable at runtime) + rshared kubelet mount via talosctl patch mc a package (open-iscsi) installed by provision.sh; /var/lib/longhorn is an ordinary directory LONGHORN_PREP_REQUIRED
kube-proxy always installed by the bootstrap, not replaceable here optional: replaceable by Cilium in eBPF (KUBE_PROXY_REPLACEMENT=true, the lab default) KUBE_PROXY_REPLACEABLE
Cilium — IPAM ipam.mode=kubernetes (podCIDRs come from kube-controller-manager) ipam.mode=cluster-pool (the Cilium operator carves the pod CIDR) CILIUM_IPAM_MODE
Cilium — OS values cgroup.autoMount=false + cgroup.hostRoot + explicit capabilities (required) none: the chart defaults are the right ones, forcing them would be harmful cilium_specific_sets()
Calico flexVolumePath: None and CSI None mandatory (/usr is read-only) same settings, but purely as a slim-down (shared manifest)
flannel (CNI=flannel) already installed by the Talos bootstrap → nothing to do installed here via the flannel/flannel chart FLANNEL_PRE_INSTALLED
Trivy — "node" scanners disabled: the node-collector bind-mounts /etc/systemdread-only file system enabled: those paths exist and are readable TRIVY_NODE_COLLECTOR
Prometheus — control plane etcd/scheduler/controller-manager monitors off (not scrapable without dedicated TLS) on: bind-address: 0.0.0.0 and listen-metrics-urls set at bootstrap KPS_SCRAPE_CONTROL_PLANE
Host-only interface enp0s8 eth1 or enp0s8 depending on the box → detected into _out/cluster.env DEFAULT_HOSTONLY_IF
Cluster facts _out/controlplane.yaml (podSubnets) _out/cluster.env (CIDR, interface, kube-proxy)
Demo Vault KV mount talos-lab/ kubeadm-lab/ VAULT_KV_MOUNT
Self-signed CA O=Vagrant-Talos lab O=Vagrant-KubeADM lab CA_ORG, CA_FILE_NAME
API-server OIDC flags (dex/) talosctl patch mcthe machine configuration is an API; extraArgs is a map kubeadm-config ConfigMap + kubeadm init phase on each control plane; extraArgs is a list of {name, value} (v1beta4) APISERVER_OIDC_PATCH, APISERVER_OIDC_MECHANISM, apiserver_oidc_commands()

Everything else — Argo CD, Kyverno, MinIO, CloudNativePG, Vault, Envoy Gateway, cert-manager, chaoskube, node-problem-detector, WordPress — is strictly identical on both distributions.

🗂️ Repository layout#

install.sh                  entry point: ./install.sh [talos|kubeadm] <component...>
platform-up.sh              the base platform (CNI → Gateway → metrics → TLS)
metric-server.yaml          metrics-server (applied by platform-up.sh)
lib/
  common.sh                 shared core: distro resolution, lab.env reading, helpers
  profiles/talos.sh         EVERYTHING specific to Talos
  profiles/kubeadm.sh       EVERYTHING specific to kubeadm/Debian
<component>/
  <component>-up.sh         the all-in-one install
  values.yaml / *.yaml      manifests and values (NEUTRAL values, substituted on the fly)
  README.md / LISEZ-MOI.md  EN/FR docs + guided walkthrough
docs/build.py               builds the single-page site from every README (make docs)
Makefile                    docs, docs-check, validate — everything that runs without a cluster

In submodule layout, that whole tree hangs under the lab's _k8s/./_k8s/install.sh, ./_k8s/longhorn/longhorn-up.sh, and so on. Nothing else moves: paths inside this repository are the same either way, which is why every walkthrough is written from this repository's root.

There is no Vagrantfile, no _out/ and no lab.env here, by design: this repo owns the manifests, the lab owns the cluster and its state. That absence is load-bearing — it is what makes "the parent holds a Vagrantfile" an unambiguous way to spot the lab, and what keeps a stray second lab.env from ever competing with the real one. See Where lab.env and _out/ are found.

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

bootstrapped cluster  (Talos lab or kubeadm lab — nodes NotReady, no CNI yet)
   │
   ├─ 1. CNI              cilium/ (default, + L2 pool → LoadBalancer IP .200)
   │                      or calico/ (CNI only) or flannel (CNI only) or nothing
   │     + L2 announcer   metallb/ — ONLY when the CNI is not cilium, on the SAME range
   │                      (skipped with METALLB=false: no LoadBalancer IP at all)
   ├─ 2. envoy-gateway/   Envoy controller + main-gateway (listeners :80 and :443)
   ├─ 3. metric-server    metrics.k8s.io API  (kubectl top, HPA)
   └─ 4. wildcard TLS     *.<LAB_DOMAIN> — two modes, per SELF_SIGNED
              │             true (default) → self-signed/   openssl, local CA
              │             false          → cert-manager/  Let's Encrypt DNS-01 Cloudflare
              │
              └─ add-ons: storage → backup → databases → identity → secrets → observability
                          → security

That is exactly the order of platform-up.sh ([1/4][4/4]). Both TLS modes fill the same Secret (wildcard-<LAB_DOMAIN with dashes>-tls), so no add-on ever has to know which one you picked.

🌐 LAB_DOMAIN — the UI domain#

Alongside it, two more neutral markers. The repository is public: no manifest carries a real value. Three neutral markers are substituted on the fly (the render helper in lib/common.sh), never rewriting a versioned file — git status stays clean:

Versioned marker Replaced with Comes from
lab.example.io $LAB_DOMAIN (default <distro>.lab.example.io) environment, then lab.env
lab-example-io $LAB_DOMAIN_DASH — the wildcard TLS Secret name derived from LAB_DOMAIN
lab-kv $VAULT_KV_MOUNT (talos-lab / kubeadm-lab) distribution profile

LAB_DOMAIN is set in the lab's lab.env, never here — so from the lab root, whichever layout you are in:

echo 'LAB_DOMAIN=k8s.my-domain.tld' >> lab.env      # in Vagrant-Talos/ or Vagrant-KubeADM/

⚠️ A domain that stays at <distro>.lab.example.io while lab.env says otherwise is the first symptom of a lab.env that was never found: check which lab got resolved (the summary line) before suspecting the substitution.

⚠️ Manifests applied by hand (without a *-up.sh) get no substitution: wordpress-example/wordpress-mariadb.yaml, vault-secret-operator/k8s/*.yaml, cert-manager/04-gateway-https-example.yaml. Pipe them through the same sed:

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

📦 Pinned versions#

Audited on 1 August 2026: everything is on the latest stable release published at that date (helm search repo <chart> --versions). Every version is overridable by environment variable.

Component Chart / image Version Where Variable
Cilium cilium/cilium 1.20.0 cilium/cilium-up.sh CILIUM_VERSION
Calico projectcalico/tigera-operator v3.32.1 calico/calico-up.sh CALICO_VERSION
MetalLB metallb/metallb 0.16.1 metallb/metallb-up.sh METALLB_VERSION
Envoy Gateway oci://docker.io/envoyproxy/gateway-helm 1.8.3 platform-up.sh ENVOY_GW_VERSION
cert-manager jetstack/cert-manager v1.21.1 platform-up.sh CERT_MANAGER_VERSION
metrics-server image registry.k8s.io/… v0.9.0 metric-server.yaml
Longhorn longhorn/longhorn 1.12.0 longhorn/longhorn-up.sh LONGHORN_VERSION
Velero vmware-tanzu/velero 12.1.0 (app v1.18.1) velero/velero-up.sh VELERO_VERSION
velero-plugin-for-aws image velero/velero-plugin-for-aws v1.14.2 velero/velero-up.sh VELERO_AWS_PLUGIN_VERSION
local-path-provisioner image rancher/… v0.0.36 local-path-storage/local-path-storage.yaml
CloudNativePG cnpg/cloudnative-pg 0.29.0 (app 1.30.0) cloudnative-pg/cloudnative-pg-up.sh CNPG_VERSION
Keycloak keycloak-k8s-resources (operator and server) 26.7.0 keycloak/keycloak-up.sh KEYCLOAK_VERSION
Dex dex/dex 0.24.1 (app v2.44.0) dex/dex-up.sh DEX_VERSION
kubelogin host binary kubectl oidc-login v1.36.3 (reference) dex/README.md
Vault hashicorp/vault 0.34.0 vault-cluster/vault-up.sh VAULT_CHART_VERSION
Vault Secrets Operator hashicorp/vault-secrets-operator 1.5.0 vault-secret-operator/vso-up.sh VSO_VERSION
kube-prometheus-stack prometheus-community/… 88.0.1 (op. v0.93.0) observability/observability-up.sh KPS_VERSION
Loki grafana/loki 7.2.0 (app 3.6.11) idem LOKI_VERSION
Alloy grafana/alloy 1.11.0 (app v1.18.0) idem ALLOY_VERSION
node-problem-detector deliveryhero/… 2.3.14 (app v0.8.19) node-problem-detector/…-up.sh NPD_VERSION
Kyverno kyverno/kyverno 3.8.2 (app v1.18.2) kyverno/kyverno-up.sh KYVERNO_VERSION
Policy Reporter policy-reporter/policy-reporter 3.9.1 kyverno/, trivy-operator/ POLICY_REPORTER_VERSION
Trivy Operator aqua/trivy-operator 0.34.0 (app 0.32.0) trivy-operator/…-up.sh TRIVY_OPERATOR_VERSION
Argo CD argo/argo-cd 10.2.2 (app v3.4.6) argocd/argocd-up.sh ARGOCD_VERSION
chaoskube chaoskube/chaoskube 0.6.0 (app 0.39.0) chaos-kube/chaoskube-up.sh CHAOSKUBE_VERSION
flannel flannel/flannel not pinned (latest: v0.28.8) platform-up.sh FLANNEL_VERSION

ℹ️ Demo images (WordPress, MariaDB, nginx, alpine, busybox, PostgreSQL, MinIO) are pinned on purpose and are not part of this audit: bumping them only matters when a demo breaks.

🗺️ The catalogue#

./install.sh list prints the same list, always up to date.

ℹ️ The commands below spell out <distro> to stay unambiguous, but it is optional in both layouts: ./_k8s/install.sh longhorn from a lab root, ./install.sh longhorn from here with a single lab next door.

🌐 Networking & TLS#

Directory Purpose Command
cilium/ default CNI + LoadBalancer IP pool + L2 announcement (ARP) ./install.sh <distro> cilium
calico/ alternative CNI (Tigera operator) — CNI only, no L2 announcement ./install.sh <distro> calico
metallb/ L2 announcer (ARP) — the LoadBalancer IPs when the CNI is not Cilium via platform when CNI != cilium
envoy-gateway/ Envoy controller + main-gateway (:80/:443) + demo apps via platform
self-signed/ default TLS mode — wildcard signed by a local CA via platform
cert-manager/ automatic wildcard TLS (ACME DNS-01 Cloudflare) via platform when SELF_SIGNED=false

💾 Storage#

Directory Purpose Command StorageClass
longhorn/ replicated block storage (iSCSI prerequisite differs per distro) ./install.sh <distro> longhorn longhorn, longhorn-r1
local-path-storage/ dynamic local storage (hostPath; path differs per distro) ./install.sh <distro> local-path local-path
minio-s3/ S3 object storage + console, 1 node ./install.sh <distro> minio
minio-s3/cluster/ distributed MinIO, 4 nodes (EC:2) — the backup target ./install.sh <distro> minio-cluster

🗃️ Backup#

Directory Purpose Command Prerequisites
velero/ backup/restore of the objects and of the PV data (Longhorn included, through FSB/kopia) into MinIO ./install.sh <distro> velero a MinIO (minio-cluster or minio)

🐘 Databases#

Directory Purpose Command Prerequisites
cloudnative-pg/ PostgreSQL HA operator + 3-node cluster, automatic failover, S3 backups + PITR ./install.sh <distro> cnpg SC longhorn-r1

🪪 Identity#

Directory Purpose Command Prerequisites
keycloak/ Keycloak through its operator, declared lab realm, OIDC issuer at keycloak.<LAB_DOMAIN> ./install.sh <distro> keycloak cnpg, SC longhorn-r1
dex/ Dex in front of Keycloak — kubectl login over OIDC (oidc-login), rights driven by a group ./install.sh <distro> dex keycloak

🔐 Secrets#

Directory Purpose Command Prerequisites
vault-cluster/ Vault HA (Raft), 3 nodes, HTTPS UI/API ./install.sh <distro> vault SC longhorn
vault-secret-operator/ Vault secrets → native K8s Secrets (static KV, dynamic DB, PKI) ./install.sh <distro> vso (operator only) + vault/*.sh unsealed Vault

📈 Observability#

Directory Purpose Command Prerequisites
observability/ Prometheus + Grafana + Alertmanager + Loki + Alloy ./install.sh <distro> observability SC longhorn-r1, CP ≥ 4 GB
node-problem-detector/ node health (kernel) ./install.sh <distro> npd
chaos-kube/ chaos: deletes 1 random pod per hour ./install.sh <distro> chaos

🛡️ Security#

Directory Purpose Command Prerequisites
kyverno/ policy engine + Policy Reporter (UI), teaching policies in Audit ./install.sh <distro> kyverno main-gateway
trivy-operator/ continuous scanner (CVEs, config, secrets, RBAC) ./install.sh <distro> trivy kyverno (shared UI)

🧪 Demos#

Directory Purpose Command
argocd/ Argo CD (GitOps), UI at argo.<LAB_DOMAIN> ./install.sh <distro> argocd
wordpress-example/ WordPress + MariaDB on Longhorn, exposed through Envoy kubectl apply (see README)

🌍 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 Kubernetes API (:6443) and SSH on every node.

  2. Name + TLS — a public Cloudflare wildcard *.<LAB_DOMAIN> → 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.

💡 With the default SELF_SIGNED=true, none of this is needed: an /etc/hosts line pointing at 192.168.56.200 is enough, and the domain never has to resolve publicly.

⚠️ Pitfalls#

  • Both labs cloned side by side, and no distribution argument. The neighbour signal then points two ways at once: the scripts stop and ask rather than pick. Say which one — ./install.sh talos … — or run from the lab you mean, in submodule layout.
  • The lab directory renamed, in sibling layout. Both the lab lookup (LAB_REPO_NAME) and the neighbour detection match those two names literally: a Vagrant-KubeADM-v2/ next door is found by neither, and resolution falls back to this repo — silently, with the probe on whatever kubectl context happens to be ambient. LAB_DIR is the fix. In submodule layout the directory name is irrelevant.
  • Read the summary line, every time. One line per script, printed before anything is touched: profile, domain, and whether a lab.env was found. It is the cheapest way to catch a resolution that went somewhere unexpected.
  • A git pull in a lab does not move _k8s/. The submodule stays pinned to its previous commit: git submodule update --init --recursive after every pull.
  • Two default StorageClasses. longhorn/values.yaml sets persistence.defaultClass: true and local-path-storage.yaml sets the is-default-class: "true" annotation. With both add-ons installed ⇒ a PVC without an explicit storageClassName lands on the most recently created SC, non-deterministically. Always name your SC.
  • This layer needs a LoadBalancer Service that really gets an IP, and Cilium is the only CNI here that provides one on its own (L2/ARP announcement). With calico, flannel or none, platform-up.sh installs metallb/ right after the CNI, on the same range — so every CNI now ends up with a reachable .200. Two things still leave the Gateway at EXTERNAL-IP <pending>: METALLB=false in lab.env, and applying envoy-gateway/Envoy-Proxy.yml by hand with its Cilium loadBalancerClass left in.
  • Never MetalLB and Cilium. Two announcers on one range means two nodes answering ARP for .200: the entry point then works "one time in two", with nothing in any log to say so. metallb-up.sh refuses to install on a Cilium cluster, and platform never picks it there.
  • Switching CNI on a live cluster is not supported: flatten the cluster from the lab (./kubeadm/cluster-reset.sh, or vagrant destroy), then re-bootstrap.
  • The repo's own Kyverno policies are violated by the repo (require-requests-limits demands a limits.cpu the in-house manifests deliberately never set). The report is 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 in the lab's lab.env: stacking these add-ons on 2 GB control planes starves etcd. observability needs 4096.

📚 References#

Both labs mount this repository as their _k8s/ submodule; each one documents its own bootstrap, its lab.env and its lifecycle. The relative paths that used to point at ../Vagrant-Talos/ and ../Vagrant-KubeADM/ are gone: they resolved to nothing on the published site, and to two different places depending on the layout.

☸️k8s-playground — les ressources Kubernetes des labs Talos et kubeadm

Un seul dépôt de manifestes et de charts pour deux labs Vagrant. La couche applicative (CNI, Gateway API, stockage, secrets, observabilité, sécurité) était dupliquée, dans Vagrant-Talos/_k8s/ puis encore dans Vagrant-KubeADM/_k8s/. Elle vit maintenant ici, une seule fois — et les deux labs montent ce dépôt à ce même emplacement _k8s/, en sous-module git. Monté ainsi, le lab et sa distribution sont trouvés tout seuls — pas d'argument, pas de variable d'environnement, pas de lab.env ici :

./_k8s/platform-up.sh                # depuis la racine de l'un ou l'autre lab (sous-module)
./_k8s/install.sh platform           # la même chose, par le point d'entrée
./install.sh kubeadm platform        # depuis la racine de ce dépôt    (dépôts voisins)

📖 Documentation navigable : https://ops-nc.github.io/k8s-playground/ — une page unique et autonome, bilingue (EN/FR) avec thème sombre/clair, régénérée depuis ces mêmes README.md / LISEZ-MOI.md à chaque push sur main (make docs pour la construire en local).

Les deux labs restent responsables du bootstrap du cluster (VM, OS, kubeadm init / talosctl bootstrap). Ce dépôt ne s'occupe que de ce qui vient après, avec kubectl et helm depuis l'hôte — y compris le CNI, car aucun des deux bootstraps ne laisse un réseau pod utilisable.

⚡ Démarrage rapide#

On ne démarre jamais ici : on démarre dans un lab, là où vivent le cluster et son état. Ce dépôt y est monté en sous-module, sur _k8s/.

# 1. Le lab — sous-module compris (jumeau Talos : OPS-NC/Vagrant-Talos)
git clone --recurse-submodules https://github.com/OPS-NC/Vagrant-kubeadm.git
cd Vagrant-kubeadm
cp lab.env.example lab.env               # le modèle du LAB : topologie, domaine, TLS, CNI
vagrant up && ./kubeadm/cluster-up.sh    # les nodes sont NotReady : pas encore de CNI

# 2. Poser la plateforme de base — rien à déclarer
./_k8s/platform-up.sh                # CNI → Envoy Gateway → metrics-server → wildcard TLS

# 3. Les addons, à la carte
./_k8s/install.sh longhorn vault argocd
./_k8s/install.sh list               # le catalogue complet
./_k8s/install.sh all                # plateforme + tous les addons, dans le bon ordre

💡 Rien à déclarer, et c'est tout l'intérêt. Depuis la racine du lab, ce dépôt est <lab>/_k8s : le lab est son parent, reconnu à son Vagrantfile, et la distribution se lit sur la structure de ce lab — kubeadm/cluster-up.sh ici, talos/cluster-up.sh chez le jumeau Talos. LAB_DIR n'est plus nécessaire, l'argument talos/kubeadm non plus. KUBECONFIG est dérivé de la même façon (<lab>/kubeconfig) ; ne l'exporte que pour tes propres appels kubectl, pas pour les scripts.

Passer la distribution explicitement reste possible, et reste prioritaire — utile pour être sûr de ce qu'on lance, ou en disposition voisine ci-dessous :

./_k8s/install.sh kubeadm platform
Variante — les deux dépôts côte à côte (la disposition d'avant, toujours supportée)
cd ../Vagrant-Talos    && ./talos/cluster-up.sh        # ou
cd ../Vagrant-KubeADM  && ./kubeadm/cluster-up.sh

cd ../k8s-playground                 # ce dépôt, voisin du lab
./install.sh talos platform          # un seul lab à côté ⇒ l'argument est facultatif ici aussi

Ici le dépôt du lab est à côté de celui-ci, pas au-dessus : la règle du parent-Vagrantfile ne se déclenche donc pas. Le dossier du lab est trouvé par LAB_REPO_NAME (../Vagrant-Talos, ../Vagrant-KubeADM), que pose le profil de distribution, et la distribution elle-même se lit sur ces deux mêmes dossiers candidats, testés par leur nom. Corollaire : avec les deux labs clonés côte à côte, le choix est ambigu — les scripts refusent alors de deviner et tu passes talos/kubeadm. C'est la seule différence entre les deux dispositions.

Chaque composant reste lançable seul, nu en disposition sous-module :

./_k8s/longhorn/longhorn-up.sh               # depuis la racine du lab  (sous-module)
./observability/observability-up.sh talos    # depuis la racine d'ici   (dépôts voisins)

🎓 Mode formation. Chaque dossier a un README.md (EN) / LISEZ-MOI.md (FR) avec la section « Pas à pas guidé » : la même installation, mais commande par commande, avec ce qu'il faut observer à chaque étape et les variantes propres à chaque distribution. Le script « tout-en-un » et le pas-à-pas font strictement la même chose.

🧩 Deux dispositions : sous-module ou dépôts voisins#

Les deux labs montent ce dépôt en sous-module git, sur _k8s/, à leur racine. C'est la disposition normale, celle que supposent toutes les commandes des labs. Garder les deux dépôts côte à côte — ce qui existait avant — fonctionne toujours et reste documenté ici comme la variante.

Sous-module (recommandée) Dépôts voisins (variante)
Sur le disque Vagrant-KubeADM/_k8s/ est ce dépôt Vagrant-KubeADM/ et k8s-playground/ dans le même dossier parent
Où l'on lance les scripts depuis la racine du lab : ./_k8s/install.sh … depuis la racine de ce dépôt : ./install.sh <distro> …
LAB_DIR inutile : le parent porte un Vagrantfile ⇒ règle 2 ci-dessous inutile aussi : la règle 3 ci-dessous trouve ../<dépôt du lab> toute seule
Argument de distribution inutile : lu sur la structure du lab inutile avec un seul lab à côté (../Vagrant-Talos / ../Vagrant-KubeADM sont testés directement) ; nécessaire si les deux sont là, ou si le dossier a été renommé
Version de la couche applicative épinglée par le lab sur un commit ⇒ le lab est reproductible celle qui traîne à côté, quelle qu'elle soit
Comment l'obtenir git clone --recurse-submodules …, ou git submodule update --init --recursive un git clone par dépôt
Comment la mettre à jour git submodule update --remote _k8s, puis committer le pointeur déplacé git pull ici
Contrainte de nommage aucune : _k8s/ est imposé par les labs, et les deux l'utilisent le dossier du lab doit s'appeler exactement Vagrant-Talos / Vagrant-KubeADM (LAB_REPO_NAME), sinon LAB_DIR de nouveau

Installer et mettre à jour le sous-module, depuis le lab :

git clone --recurse-submodules https://github.com/OPS-NC/Vagrant-kubeadm.git
git submodule update --init --recursive   # remplit _k8s/ sur un clone déjà fait
git submodule update --remote _k8s        # amène _k8s/ sur le dernier commit de ce dépôt

⚠️ Un git pull dans le lab ne met PAS le sous-module à jour. Il ne déplace que le dépôt du lab ; _k8s/ reste sur le commit épinglé avant, et l'on jouerait alors les commandes documentées contre une couche applicative plus ancienne. Enchaîne chaque pull avec git submodule update --init --recursive. Et un _k8s/ vide — ./_k8s/install.sh: No such file or directory — ne veut jamais dire qu'une chose : le sous-module n'a jamais été initialisé.

💡 Modifier cette couche, c'est modifier ici. Vu depuis un lab, _k8s/ est une copie de travail de ce dépôt sur un HEAD détaché : les fichiers qu'on y édite appartiennent à ce dépôt, pas au lab, et un git commit lancé dans _k8s/ committe ici. Le chemin est donc : PR sur ce dépôt → merge → dans le lab, git submodule update --remote _k8s, puis commit du pointeur bumpé (git status affiche modified: _k8s (new commits)). Ce commit est la montée de version de la couche applicative, et c'est lui qui garde le lab reproductible.

📍 Où sont trouvés lab.env et _out/#

Les scripts ne stockent rien. lab.env (l'intention : domaine, mode TLS, CNI, taille des VM) et _out/ (les faits : talosconfig, cluster.env, l'AC locale dans _out/self-signed/, _out/vault-init.json…) vivent dans le dépôt du lab, avec le kubeconfig juste à côté, à sa racine. Il n'y a qu'une source de vérité pour la topologie, celle du lab — c'est pourquoi ce dépôt ne porte ni lab.env, ni modèle de lab.env. _resolve_lab_dir(), dans lib/common.sh, cherche ce dossier dans cet ordre :

# Candidat S'applique quand
1 $LAB_DIR, à défaut le dossier qui porte $LAB_ENV l'une des deux est exportée — surcharge explicite, gagne toujours
2 le dossier parent de ce dépôt, s'il porte un Vagrantfile disposition sous-module : ce dépôt est <lab>/_k8s, le lab est donc juste au-dessus
3 <racine de ce dépôt>/../$LAB_REPO_NAMEVagrant-Talos ou Vagrant-KubeADM, posé par le profil de distribution ce dossier existe ⇒ dépôts voisins (seulement une fois le profil chargé)
4 la racine de ce dépôt un fichier lab.env ou un dossier _out/ s'y trouve (un lien symbolique compte) — usage autonome
5 repli : la racine de ce dépôt rien de ce qui précède n'a matché

KUBECONFIG suit le même dossier : s'il n'est pas déjà exporté, il devient <dossier du lab>/kubeconfig.

ℹ️ Pourquoi le test porte sur Vagrantfile, et pourquoi il passe avant la règle 4. Un Vagrantfile est la marque non ambiguë d'un lab : il est là dès le clone, avant tout vagrant up, et il n'apparaît jamais au-dessus de ce dépôt en disposition voisine — où le parent n'est qu'un dossier de travail quelconque. L'ordonner avant la règle « racine de ce dépôt » est délibéré : un _out/ résiduel (ou un lab.env déposé ici pour un test ponctuel) ne doit jamais masquer le vrai lab qui se trouve juste au-dessus. LAB_DIR / LAB_ENV restent au-dessus de tout : une surcharge qu'on peut mettre en minorité n'est pas une surcharge.

⚠️ C'est exactement la panne que la règle du parent supprime. Avant elle, la disposition sous-module résolvait <racine>/../Vagrant-KubeADM en Vagrant-KubeADM/Vagrant-KubeADM — un chemin qui n'existe pas — et retombait sur ce dépôt lui-même, qui ne porte ni lab.env ni _out/. Rien ne cassait bruyamment : les scripts tournaient avec les défauts du profil<distro>.lab.example.io au lieu de ton LAB_DOMAIN (donc un Secret TLS wildcard sous un nom qu'aucun addon n'irait chercher), CNI=cilium au lieu du CNI choisi, POD_CIDR / HOSTONLY_IF / KUBE_PROXY_REPLACEMENT DEVINÉS au lieu de ceux détectés par cluster-up.sh, et un KUBECONFIG pointant sur un fichier inexistant. Si tu revois cette forme de symptôme, c'est un problème de résolution : lis d'abord la ligne de résumé.

💡 Un signal se déclenche quand même, sur kubeadm seulement. platform-up.sh avertit quand _out/cluster.env est absent — « ./kubeadm/cluster-up.sh n'a pas (ou pas jusqu'au bout) été lancé ». Deux lectures : soit le bootstrap n'est effectivement pas allé au bout, soit ce n'est pas le bon lab qui a été résolu. L'avertissement est non bloquant, et sur Talos il n'a pas d'équivalent (ce lab n'a pas de cluster.env).

Chaque script affiche sa résolution avant de toucher à quoi que ce soit — une ligne, à lire :

    profil kubeadm (Debian 13 + kubeadm) · domaine *.kubeadm.lab.example.io · lab.env absent (défauts)

lab.env absent (défauts) sur un lab qui a pourtant un lab.env, ou un domaine qui n'est pas celui que tu as posé : la résolution a manqué le lab — force-la avec LAB_DIR.

Tout surcharger à la main reste possible — utile pour un lab rangé ailleurs, ou pour exercer ce dépôt isolément :

LAB_DIR=~/labs/mon-lab   ./install.sh talos platform      # lab.env + _out/ + kubeconfig, tout est là
LAB_ENV=~/labs/mon-lab/lab.env  ./install.sh talos platform   # son dossier devient le dossier du lab
LAB_ENV=~/labs/mon-lab/lab.env  KUBECONFIG=~/labs/mon-lab/kubeconfig  ./install.sh talos platform

⚠️ Ne crée pas de lab.env à la racine de ce dépôt. La règle 4 en accepte un, et il est là pour exercer ce dépôt sans aucun lab — mais un second lab.env est une seconde vérité, qui diverge de celle du lab dès que l'une des deux bouge. C'est précisément pour ça qu'il n'y a pas de lab.env.example ici : le modèle à copier vit dans le lab.

🎯 Comment la distribution est choisie#

Par ordre de priorité :

# Source Exemple
1 1er argument positionnel ./install.sh talos longhorn · ./longhorn/longhorn-up.sh talos
2 --distro= ./platform-up.sh --distro=kubeadm
3 variable d'environnement K8S_DISTRO=talos ./install.sh longhorn
4 DISTRO= dans le lab.env du lab DISTRO=kubeadm
5 la structure du lab talos/cluster-up.shtalos · kubeadm/cluster-up.shkubeadm
6 les artefacts de bootstrap du lab _out/talosconfigtalos · _out/cluster.envkubeadm
7 le dépôt du lab voisin ../Vagrant-Talos/talos/cluster-up.shtalos · ../Vagrant-KubeADM/kubeadm/cluster-up.shkubeadm
8 sondage du cluster osImage du 1er node : Talos …talos, sinon kubeadm

Les sources 5 à 8 sont _detect_distro(), quatre familles de signaux classées par la précocité avec laquelle elles deviennent disponibles :

Signal Disponible dès Coût
structure — le script de bootstrap de la distribution que le lab implémente le git clone, avant tout vagrant up nul : un test de fichier
artefacts — ce que le bootstrap a écrit dans _out/ après cluster-up.sh nul : un test de fichier. Couvre un lab dont les dossiers ont été renommés
voisins — le même test appliqué à ../Vagrant-Talos et ../Vagrant-KubeADM le git clone du lab nul, mais refuse de trancher si les deux sont là (cf. ci-dessous)
sondagekubectl get nodes -o jsonpath=…osImage un cluster debout et un KUBECONFIG qui pointe déjà dessus dernier recours seulement

ℹ️ La source 7 code les deux noms en dur, volontairement. LAB_REPO_NAME aurait été la façon naturelle de nommer le voisin, mais c'est le profil qui le pose — et le profil est précisément ce qu'on cherche à choisir. Les deux candidats sont donc testés directement. Si les deux sont présents à côté, rien n'est tranché et les scripts redemandent : deviner reviendrait à une chance sur deux de déployer sur le mauvais cluster. Dans ce cas, passe talos/kubeadm.

ℹ️ Pourquoi le sondage est en dernier. Il exige un KUBECONFIG déjà correct — or KUBECONFIG est dérivé du dossier du lab, qui vient du profil, qui n'est chargé qu'une fois la distribution connue. Par construction, le sondage est donc le signal le moins disponible à l'instant précis où on en a besoin : il ne répond jamais que pour un contexte kubectl ambiant. Les sources 5 et 6 lisent le lab directement et n'ont pas cette dépendance.

Sans aucune de ces sources, les scripts refusent de démarrer : appliquer un manifeste pensé pour Talos sur Debian (ou l'inverse) ne produit pas une erreur franche mais une panne silencieuse — un Deployment créé dont aucun pod ne démarre, par exemple.

ℹ️ Les sources 4 à 6 exigent que le lab soit localisé d'abord — et c'est là que la disposition compte. En sous-module, la règle du parent-Vagrantfile s'applique avant tout chargement de profil : les trois fonctionnent donc nues, et c'est ce qui rend ./_k8s/platform-up.sh autosuffisant. En dépôts voisins, aucun lab n'est localisé à cet instant (LAB_REPO_NAME vient du profil) : ces trois-là restent muettes et la source 7 prend le relais — c'est pourquoi ./install.sh platform marche nu depuis ici aussi, tant qu'il n'y a qu'un seul lab à côté. Deux labs côte à côte, ou un dossier renommé : passe la distribution, ou exporte LAB_DIR. Un argument explicite court-circuite toute la question et reste le moyen le plus sûr de savoir ce qu'on a lancé.

🧬 Ce qui diffère vraiment entre les deux labs#

Tout est concentré dans lib/profiles/talos.sh et lib/profiles/kubeadm.sh : les scripts d'installation ne testent jamais la distribution à coups de if dispersés, ils lisent des variables.

Sujet Talos Linux Debian 13 + kubeadm Variable du profil
Domaine par défaut des UI talos.lab.example.io kubeadm.lab.example.io DEFAULT_LAB_DOMAIN
PodSecurity (niveau cluster) baseline appliqué → un pod privilégié exige un namespace étiqueté privileged, sinon échec silencieux aucun niveau appliqué → les mêmes labels ne débloquent rien, ils documentent l'intention PODSECURITY_DEFAULT
Système de fichiers immuable : / et /usr en lecture seule, seul /var est inscriptible ordinaire, tout est inscriptible
local-path-provisioner /var/local-path-provisioner /opt/local-path-provisioner (chemin amont) LOCAL_PATH_DIR
Prérequis iSCSI (Longhorn) extension iscsi-tools cuite dans l'image d'install (irrécupérable à chaud) + montage kubelet rshared via talosctl patch mc paquet open-iscsi posé par provision.sh ; /var/lib/longhorn est un dossier ordinaire LONGHORN_PREP_REQUIRED
kube-proxy toujours posé par le bootstrap, non remplaçable ici optionnel : remplaçable par Cilium en eBPF (KUBE_PROXY_REPLACEMENT=true, défaut du lab) KUBE_PROXY_REPLACEABLE
Cilium — IPAM ipam.mode=kubernetes (podCIDR posés par le kube-controller-manager) ipam.mode=cluster-pool (l'opérateur Cilium découpe le CIDR pod) CILIUM_IPAM_MODE
Cilium — valeurs OS cgroup.autoMount=false + cgroup.hostRoot + capabilities explicites (exigés) aucune : les défauts du chart sont les bons, les forcer serait nuisible cilium_specific_sets()
Calico flexVolumePath: None et CSI None obligatoires (/usr en lecture seule) mêmes réglages, mais comme simple allègement (manifeste commun)
flannel (CNI=flannel) déjà posé par le bootstrap Talos → rien à installer installé ici par le chart flannel/flannel FLANNEL_PRE_INSTALLED
Trivy — scanners « node » désactivés : le node-collector bind-monte /etc/systemdread-only file system activés : les chemins existent et sont lisibles TRIVY_NODE_COLLECTOR
Prometheus — control plane moniteurs etcd/scheduler/controller-manager coupés (non scrutables sans TLS dédié) activés : bind-address: 0.0.0.0 et listen-metrics-urls posés au bootstrap KPS_SCRAPE_CONTROL_PLANE
Interface host-only enp0s8 eth1 ou enp0s8 selon la box → détectée dans _out/cluster.env DEFAULT_HOSTONLY_IF
Faits du cluster _out/controlplane.yaml (podSubnets) _out/cluster.env (CIDR, interface, kube-proxy)
Moteur KV Vault de démo talos-lab/ kubeadm-lab/ VAULT_KV_MOUNT
AC auto-signée O=Vagrant-Talos lab O=Vagrant-KubeADM lab CA_ORG, CA_FILE_NAME
Flags OIDC de l'apiserver (dex/) talosctl patch mcla configuration machine est une API ; extraArgs est une map ConfigMap kubeadm-config + kubeadm init phase sur chaque control plane ; extraArgs est une liste de {name, value} (v1beta4) APISERVER_OIDC_PATCH, APISERVER_OIDC_MECHANISM, apiserver_oidc_commands()

Tout le reste — Argo CD, Kyverno, MinIO, CloudNativePG, Vault, Envoy Gateway, cert-manager, chaoskube, node-problem-detector, WordPress — est strictement identique sur les deux distributions.

🗂️ Organisation du dépôt#

install.sh                  point d'entrée : ./install.sh [talos|kubeadm] <composant...>
platform-up.sh              la plateforme de base (CNI → Gateway → metrics → TLS)
metric-server.yaml          metrics-server (appliqué par platform-up.sh)
lib/
  common.sh                 socle commun : résolution de la distro, lecture lab.env, helpers
  profiles/talos.sh         TOUT ce qui est propre à Talos
  profiles/kubeadm.sh       TOUT ce qui est propre à kubeadm/Debian
<composant>/
  <composant>-up.sh         l'installation « tout-en-un »
  values.yaml / *.yaml      manifestes et values (valeurs NEUTRES, substituées à la volée)
  README.md / LISEZ-MOI.md  doc EN/FR + pas-à-pas guidé
docs/build.py               construit la page unique depuis tous les README (make docs)
Makefile                    docs, docs-check, validate — tout ce qui tourne sans cluster

En sous-module, tout cet arbre est sous le _k8s/ du lab — ./_k8s/install.sh, ./_k8s/longhorn/longhorn-up.sh, etc. Rien d'autre ne bouge : les chemins internes à ce dépôt sont les mêmes dans les deux dispositions, et c'est pour ça que chaque pas-à-pas est écrit depuis la racine de ce dépôt.

Il n'y a ni Vagrantfile, ni _out/, ni lab.env ici, et c'est voulu : ce dépôt porte les manifestes, le lab porte le cluster et son état. Cette absence est structurante — c'est elle qui fait de « le parent porte un Vagrantfile » un repère non ambigu, et qui empêche un second lab.env de venir concurrencer le vrai. Cf. Où sont trouvés lab.env et _out/.

🔗 Chaîne de dépendances#

Chaque maillon suppose le précédent : pas d'IP LoadBalancer sans annonceur L2, pas de HTTPS sans Gateway, pas d'UI sans certificat sur l'écouteur :443.

cluster bootstrapé  (lab Talos ou lab kubeadm — nodes NotReady, pas encore de CNI)
   │
   ├─ 1. CNI              cilium/ (défaut, + pool L2 → IP LoadBalancer .200)
   │                      ou calico/ (CNI seul) ou flannel (CNI seul) ou rien
   │     + annonceur L2   metallb/ — UNIQUEMENT si le CNI n'est pas cilium, sur la MÊME plage
   │                      (sauté avec METALLB=false : plus aucune IP LoadBalancer)
   ├─ 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     *.<LAB_DOMAIN> — deux modes selon SELF_SIGNED
              │             true (défaut) → self-signed/   openssl, AC locale
              │             false         → cert-manager/  Let's Encrypt DNS-01 Cloudflare
              │
              └─ addons : stockage → sauvegarde → bases → identité → secrets
                          → observabilité → sécurité

C'est exactement l'ordre de platform-up.sh ([1/4][4/4]). Les deux modes TLS remplissent le même Secret (wildcard-<LAB_DOMAIN en tirets>-tls) : aucun addon n'a jamais à savoir lequel a été choisi.

🌐 LAB_DOMAIN — le domaine des UI#

À ses côtés, deux autres marqueurs neutres. Le dépôt est public : aucun manifeste ne porte de vraie valeur. Trois marqueurs neutres sont substitués à la volée (fonction render de lib/common.sh), sans jamais réécrire un fichier versionné — git status reste propre :

Marqueur versionné Remplacé par Vient de
lab.example.io $LAB_DOMAIN (défaut <distro>.lab.example.io) env, puis lab.env
lab-example-io $LAB_DOMAIN_DASH — nom du Secret TLS wildcard dérivé de LAB_DOMAIN
lab-kv $VAULT_KV_MOUNT (talos-lab / kubeadm-lab) profil de distribution

LAB_DOMAIN se pose dans le lab.env du lab, jamais ici — donc depuis la racine du lab, quelle que soit la disposition :

echo 'LAB_DOMAIN=k8s.mon-domaine.tld' >> lab.env    # dans Vagrant-Talos/ ou Vagrant-KubeADM/

⚠️ Un domaine resté à <distro>.lab.example.io alors que lab.env dit autre chose est le premier symptôme d'un lab.env jamais trouvé : vérifie quel lab a été résolu (la ligne de résumé) avant de soupçonner la substitution.

⚠️ Les manifestes appliqués à la main (sans passer par un *-up.sh) ne bénéficient pas de la substitution : wordpress-example/wordpress-mariadb.yaml, vault-secret-operator/k8s/*.yaml, cert-manager/04-gateway-https-example.yaml. Passe-les dans le même sed :

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

📦 Versions épinglées#

Audit du 1er août 2026 : tout est à la dernière version stable publiée à cette date (helm search repo <chart> --versions). Chaque version est surchargeable par variable d'environnement.

Composant Chart / image Version Variable
Cilium cilium/cilium 1.20.0 cilium/cilium-up.sh CILIUM_VERSION
Calico projectcalico/tigera-operator v3.32.1 calico/calico-up.sh CALICO_VERSION
MetalLB metallb/metallb 0.16.1 metallb/metallb-up.sh METALLB_VERSION
Envoy Gateway oci://docker.io/envoyproxy/gateway-helm 1.8.3 platform-up.sh ENVOY_GW_VERSION
cert-manager jetstack/cert-manager v1.21.1 platform-up.sh CERT_MANAGER_VERSION
metrics-server image registry.k8s.io/… v0.9.0 metric-server.yaml
Longhorn longhorn/longhorn 1.12.0 longhorn/longhorn-up.sh LONGHORN_VERSION
Velero vmware-tanzu/velero 12.1.0 (app v1.18.1) velero/velero-up.sh VELERO_VERSION
velero-plugin-for-aws image velero/velero-plugin-for-aws v1.14.2 velero/velero-up.sh VELERO_AWS_PLUGIN_VERSION
local-path-provisioner image rancher/… v0.0.36 local-path-storage/local-path-storage.yaml
CloudNativePG cnpg/cloudnative-pg 0.29.0 (app 1.30.0) cloudnative-pg/cloudnative-pg-up.sh CNPG_VERSION
Keycloak keycloak-k8s-resources (opérateur et serveur) 26.7.0 keycloak/keycloak-up.sh KEYCLOAK_VERSION
Dex dex/dex 0.24.1 (app v2.44.0) dex/dex-up.sh DEX_VERSION
kubelogin binaire hôte kubectl oidc-login v1.36.3 (référence) dex/LISEZ-MOI.md
Vault hashicorp/vault 0.34.0 vault-cluster/vault-up.sh VAULT_CHART_VERSION
Vault Secrets Operator hashicorp/vault-secrets-operator 1.5.0 vault-secret-operator/vso-up.sh VSO_VERSION
kube-prometheus-stack prometheus-community/… 88.0.1 (op. v0.93.0) observability/observability-up.sh KPS_VERSION
Loki grafana/loki 7.2.0 (app 3.6.11) idem LOKI_VERSION
Alloy grafana/alloy 1.11.0 (app v1.18.0) idem ALLOY_VERSION
node-problem-detector deliveryhero/… 2.3.14 (app v0.8.19) node-problem-detector/…-up.sh NPD_VERSION
Kyverno kyverno/kyverno 3.8.2 (app v1.18.2) kyverno/kyverno-up.sh KYVERNO_VERSION
Policy Reporter policy-reporter/policy-reporter 3.9.1 kyverno/, trivy-operator/ POLICY_REPORTER_VERSION
Trivy Operator aqua/trivy-operator 0.34.0 (app 0.32.0) trivy-operator/…-up.sh TRIVY_OPERATOR_VERSION
Argo CD argo/argo-cd 10.2.2 (app v3.4.6) argocd/argocd-up.sh ARGOCD_VERSION
chaoskube chaoskube/chaoskube 0.6.0 (app 0.39.0) chaos-kube/chaoskube-up.sh CHAOSKUBE_VERSION
flannel flannel/flannel non épinglé (dernière : v0.28.8) platform-up.sh FLANNEL_VERSION

ℹ️ Les images des démos (WordPress, MariaDB, nginx, alpine, busybox, PostgreSQL, MinIO) sont épinglées volontairement et ne font pas partie de cet audit : les mettre à jour n'a d'intérêt que si la démo casse.

🗺️ Le catalogue#

./install.sh list affiche la même liste, à jour.

ℹ️ Les commandes ci-dessous explicitent <distro> pour rester sans ambiguïté, mais il est facultatif dans les deux dispositions : ./_k8s/install.sh longhorn depuis la racine d'un lab, ./install.sh longhorn depuis ici avec un seul lab à côté.

🌐 Réseau & TLS#

Dossier Rôle Commande
cilium/ CNI par défaut + pool d'IP LoadBalancer + annonce L2 (ARP) ./install.sh <distro> cilium
calico/ CNI alternatif (opérateur Tigera) — CNI seul, pas d'annonce L2 ./install.sh <distro> calico
metallb/ annonceur L2 (ARP) — les IP LoadBalancer quand le CNI n'est pas Cilium via platform quand CNI != cilium
envoy-gateway/ contrôleur Envoy + main-gateway (:80/:443) + apps de démo via platform
self-signed/ mode TLS par défaut — wildcard signé par une AC locale via platform
cert-manager/ wildcard TLS automatique (ACME DNS-01 Cloudflare) via platform si SELF_SIGNED=false

💾 Stockage#

Dossier Rôle Commande StorageClass
longhorn/ stockage bloc répliqué (prérequis iSCSI différent selon la distro) ./install.sh <distro> longhorn longhorn, longhorn-r1
local-path-storage/ stockage local dynamique (hostPath ; chemin selon la distro) ./install.sh <distro> local-path local-path
minio-s3/ stockage objet S3 + console, 1 nœud ./install.sh <distro> minio
minio-s3/cluster/ MinIO distribué 4 nœuds (EC:2) — cible des sauvegardes ./install.sh <distro> minio-cluster

🗃️ Sauvegarde#

Dossier Rôle Commande Prérequis
velero/ sauvegarde/restauration des objets et des données des PV (Longhorn compris, via FSB/kopia) dans MinIO ./install.sh <distro> velero un MinIO (minio-cluster ou minio)

🐘 Bases de données#

Dossier Rôle Commande Prérequis
cloudnative-pg/ opérateur PostgreSQL HA + cluster 3 nœuds, bascule auto, sauvegardes S3 + PITR ./install.sh <distro> cnpg SC longhorn-r1

🪪 Identité#

Dossier Rôle Commande Prérequis
keycloak/ Keycloak par son opérateur, realm lab déclaré, issuer OIDC sur keycloak.<LAB_DOMAIN> ./install.sh <distro> keycloak cnpg, SC longhorn-r1
dex/ Dex devant Keycloak — connexion kubectl par OIDC (oidc-login), droits pilotés par un groupe ./install.sh <distro> dex keycloak

🔐 Secrets#

Dossier Rôle Commande Prérequis
vault-cluster/ Vault HA (Raft) 3 nœuds, UI/API HTTPS ./install.sh <distro> vault SC longhorn
vault-secret-operator/ secrets Vault → Secret K8s (KV statique, DB dynamique, PKI) ./install.sh <distro> vso (l'opérateur seul) + vault/*.sh Vault descellé

📈 Observabilité#

Dossier Rôle Commande Prérequis
observability/ Prometheus + Grafana + Alertmanager + Loki + Alloy ./install.sh <distro> observability SC longhorn-r1, CP ≥ 4 Go
node-problem-detector/ santé des nodes (kernel) ./install.sh <distro> npd
chaos-kube/ chaos : supprime 1 pod au hasard par heure ./install.sh <distro> chaos

🛡️ Sécurité#

Dossier Rôle Commande Prérequis
kyverno/ moteur de policies + Policy Reporter (UI), policies en Audit ./install.sh <distro> kyverno main-gateway
trivy-operator/ scanner continu (CVE, config, secrets, RBAC) ./install.sh <distro> trivy kyverno (UI partagée)

🧪 Démos#

Dossier Rôle Commande
argocd/ Argo CD (GitOps), UI sous argo.<LAB_DOMAIN> ./install.sh <distro> argocd
wordpress-example/ WordPress + MariaDB sur Longhorn, exposés par Envoy kubectl apply (cf. LISEZ-MOI)

🌍 Accès distant (Tailscale + Cloudflare)#

La 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 l'approuver dans la console Tailscale.

    ⚠️ Rester sur le /32 (ou l'encadrer par une ACL) : un /24 exposerait aussi l'API Kubernetes (:6443) et le SSH de chaque node.

  2. Nom + TLS — wildcard Cloudflare public *.<LAB_DOMAIN> → 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 → la Gateway doit porter un certificat publiquement trusté (Let's Encrypt, cf. cert-manager/). Un certificat Cloudflare Origin CA serait refusé par les navigateurs.

💡 Avec le défaut SELF_SIGNED=true, rien de tout ça n'est nécessaire : une ligne /etc/hosts pointant sur 192.168.56.200 suffit, et le domaine n'a jamais besoin de résoudre publiquement.

⚠️ Pièges#

  • Les deux labs clonés côte à côte, sans argument de distribution. Le signal « voisin » pointe alors dans les deux sens à la fois : les scripts s'arrêtent et redemandent plutôt que de choisir. Dis lequel — ./install.sh talos … — ou lance depuis le lab visé, en sous-module.
  • Le dossier du lab renommé, en dépôts voisins. La recherche du lab (LAB_REPO_NAME) comme la détection par voisinage matchent ces deux noms au pied de la lettre : un Vagrant-KubeADM-v2/ à côté n'est trouvé par aucune des deux, et la résolution retombe sur ce dépôt — silencieusement, avec le sondage du contexte kubectl ambiant. LAB_DIR est le correctif. En sous-module, le nom du dossier n'a aucune importance.
  • Lire la ligne de résumé, à chaque fois. Une ligne par script, affichée avant de toucher à quoi que ce soit : profil, domaine, et si un lab.env a été trouvé. C'est le moyen le moins cher d'attraper une résolution partie ailleurs que prévu.
  • Un git pull dans un lab ne déplace pas _k8s/. Le sous-module reste épinglé sur son commit précédent : git submodule update --init --recursive après chaque pull.
  • 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 créée en dernier, de façon non déterministe. Nomme toujours ta SC.
  • Cette couche a besoin d'un Service LoadBalancer qui obtienne réellement une IP, et Cilium est le seul CNI ici à en fournir une tout seul (annonce L2/ARP). Avec calico, flannel ou none, platform-up.sh installe metallb/ juste après le CNI, sur la même plage — tous les CNI aboutissent donc désormais à un .200 joignable. Deux choses laissent encore le Gateway en EXTERNAL-IP <pending> : METALLB=false dans lab.env, et appliquer envoy-gateway/Envoy-Proxy.yml à la main en y laissant la loadBalancerClass Cilium.
  • Jamais MetalLB et Cilium. Deux annonceurs sur une plage, ce sont deux nodes qui répondent à l'ARP pour .200 : le point d'entrée marche alors « une fois sur deux », sans qu'aucun log ne le dise. metallb-up.sh refuse de s'installer sur un cluster Cilium, et platform ne le choisit jamais là.
  • Changer de CNI à chaud n'est pas supporté : remets le cluster à plat depuis le lab (./kubeadm/cluster-reset.sh, ou vagrant destroy), puis rebootstrape.
  • Les policies Kyverno du dépôt sont violées par le dépôt lui-même (require-requests-limits exige un limits.cpu que les manifestes maison ne posent pas, volontairement). Le rapport est bruyant par construction — cf. kyverno/.
  • Les émetteurs de métriques sont coupés par défaut (serviceMonitor/podMonitor à false dans trivy-operator, CloudNativePG, node-problem-detector) : Prometheus ne scrute rien tant que tu ne les rallumes pas après avoir installé observability.
  • Ne descends jamais CP_MEM sous 3072 dans le lab.env du lab : empiler ces addons sur des control planes à 2 Go affame etcd. observability demande 4096.

📚 Références#

Les deux labs montent ce dépôt en sous-module _k8s/ ; chacun documente son propre bootstrap, son lab.env et son cycle de vie. Les chemins relatifs qui pointaient vers ../Vagrant-Talos/ et ../Vagrant-KubeADM/ ont disparu : ils ne menaient nulle part sur le site publié, et vers deux endroits différents selon la disposition.

🐝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 (./kubeadm/cluster-up.sh, the default — CNI=none is equivalent here) kubeadm init installs no CNI at all: Cilium takes that slot kubectl get nodesNotReady before the install, that is expected
_out/cluster.env present it carries the detected facts the script reads: HOSTONLY_IF, POD_CIDR, VIP, KUBE_PROXY_REPLACEMENT cat _out/cluster.env
A host-only interface (usually enp0s8) source of the ARP announcement and of the VXLAN tunnels vagrant ssh k8s-cp1 -c 'ip -br a'
kubectl + helm, KUBECONFIG set the script checks the binaries, then /readyz helm version

⚡ Install#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> cilium     # <distro> = talos | kubeadm
./cilium/cilium-up.sh <distro>

Chart cilium/cilium 1.20.0, read from lab.env (CILIUM_VERSION) and overridable per run (CILIUM_VERSION=1.20.1 ./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.

🧬 Talos vs kubeadm#

This is the most distribution-dependent component in the whole repository.

Helm value Talos kubeadm Why
ipam.mode kubernetes cluster-pool On Talos the kube-controller-manager already carves per-node podCIDRs and Cilium follows them. On kubeadm the Cilium operator owns the pool (hence clusterPoolIPv4PodCIDRList + clusterPoolIPv4MaskSize=24).
kubeProxyReplacement false (forced) KUBE_PROXY_REPLACEMENT, default true Talos always installs kube-proxy. On kubeadm, kubeadm init may have run with --skip-phases=addon/kube-proxy: Cilium must then take over in eBPF, and getting it wrong breaks every Service (CoreDNS included).
cgroup.autoMount.enabled + cgroup.hostRoot false + /sys/fs/cgroup (required) not set Talos already mounts cgroup2 and the pod cannot remount /sys/fs/cgroup (read-only). On Debian the chart handles it: forcing these would be harmful.
securityContext.capabilities.* explicit lists (required) not set Talos rejects the chart's implicit privileged.
devices enp0s8 eth1/enp0s8, detected in _out/cluster.env Without pinning, Cilium picks the NAT NIC 10.0.2.15 — identical on every VM ⇒ broken cross-node traffic and DNS.

Source: lib/profiles/<distro>.sh (CILIUM_IPAM_MODE, cilium_specific_sets(), KUBE_PROXY_REPLACEABLE, DEFAULT_HOSTONLY_IF).

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. Check the starting point#

kubectl get nodes                 # NotReady everywhere: that is EXPECTED, there is no CNI yet
kubectl get ds -A | grep -Ei 'cilium|flannel|calico'   # must be EMPTY (one CNI per cluster)

2. Collect the lab parameters#

# kubeadm: the FACTS live in _out/cluster.env (written by cluster-up.sh)
grep -E 'HOSTONLY_IF|POD_CIDR|KUBE_PROXY_REPLACEMENT' ../Vagrant-KubeADM/_out/cluster.env
# Talos: the pod CIDR is in the generated machine config
grep -A2 podSubnets ../Vagrant-Talos/_out/controlplane.yaml

3. Add the Helm repository#

helm repo add cilium https://helm.cilium.io/ && helm repo update cilium
helm search repo cilium/cilium --versions | head -3      # check the latest stable

4. Install Cilium — the command differs per distribution#

Talos
helm upgrade --install cilium cilium/cilium -n kube-system --create-namespace \
  --version 1.20.0 \
  --set envoy.enabled=false \
  --set kubeProxyReplacement=false \
  --set k8sServiceHost=192.168.56.5 --set k8sServicePort=6443 \
  --set routingMode=tunnel --set tunnelProtocol=vxlan \
  --set ipam.mode=kubernetes \
  --set l2announcements.enabled=true --set externalIPs.enabled=true \
  --set hubble.enabled=true --set hubble.relay.enabled=true --set hubble.ui.enabled=true \
  --set bandwidthManager.enabled=true \
  --set devices=enp0s8 \
  --set cgroup.autoMount.enabled=false --set cgroup.hostRoot=/sys/fs/cgroup \
  --set securityContext.capabilities.ciliumAgent="{CHOWN,KILL,NET_ADMIN,NET_RAW,IPC_LOCK,SYS_ADMIN,SYS_RESOURCE,DAC_OVERRIDE,FOWNER,SETGID,SETUID}" \
  --set securityContext.capabilities.cleanCiliumState="{NET_ADMIN,SYS_ADMIN,SYS_RESOURCE}"
kubeadm
helm upgrade --install cilium cilium/cilium -n kube-system --create-namespace \
  --version 1.20.0 \
  --set envoy.enabled=false \
  --set kubeProxyReplacement=true \
  --set k8sServiceHost=192.168.56.5 --set k8sServicePort=6443 \
  --set routingMode=tunnel --set tunnelProtocol=vxlan \
  --set ipam.mode=cluster-pool \
  --set ipam.operator.clusterPoolIPv4PodCIDRList='{10.244.0.0/16}' \
  --set ipam.operator.clusterPoolIPv4MaskSize=24 \
  --set l2announcements.enabled=true --set externalIPs.enabled=true \
  --set hubble.enabled=true --set hubble.relay.enabled=true --set hubble.ui.enabled=true \
  --set bandwidthManager.enabled=true \
  --set devices=eth1        # ⚠️ use the value DETECTED in _out/cluster.env

5. Wait for the CNI to unblock the nodes#

kubectl wait --for=condition=Ready nodes --all --timeout=300s
kubectl -n kube-system get pods -l k8s-app=cilium -o wide

6. Apply the LoadBalancer IP pool + L2 (ARP) announcement#

This is what gives the Gateway Service a real IP (the lab's "cloud provider").

sed -e 's/192\.168\.56\.200/192.168.56.200/' \
    -e 's/enp0s8/eth1/' \
    cilium/cilium-l2.yml | kubectl apply -f -     # on Talos: keep enp0s8
kubectl get ciliumloadbalancerippool
kubectl get ciliuml2announcementpolicy

7. Verify#

kubectl -n kube-system exec ds/cilium -- cilium-dbg status --verbose | head -30
# kube-proxy replaced? (kubeadm with KUBE_PROXY_REPLACEMENT=true)
kubectl -n kube-system get ds kube-proxy 2>&1 | tail -1

🔧 What the script does#

  1. Reads _out/cluster.env (then lab.env as a fallback): HOSTONLY_IF, POD_CIDR, VIP, KUBE_PROXY_REPLACEMENT — detected facts, not guesses;
  2. installs Cilium with Helm in kube-system with the values below;
  3. waits for condition=Ready on every node (300 s max) — the CNI is what unblocks them;
  4. applies cilium-l2.yml: LoadBalancer IP pool + ARP announcement policy, with the pool range and the interface name substituted in.

The --set flags that matter#

Setting Why
devices=<HOSTONLY_IF> the key one: pins the host-only NIC (read from _out/cluster.env, never hardcoded). 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. There is no router on the host-only network, so native routing would need a static route per node on the VirtualBox side: tunnelling avoids the whole problem
ipam.mode=cluster-pool + ipam.operator.clusterPoolIPv4PodCIDRList={<POD_CIDR>} + clusterPoolIPv4MaskSize=24 ⚠️ the trap: the cluster-pool default is 10.0.0.0/8, completely independent from the podSubnet declared to kubeadm. Without passing POD_CIDR back explicitly, kubeadm and Cilium do not talk about the same network
kubeProxyReplacement=<KUBE_PROXY_REPLACEMENT> true (default): kubeadm init ran with --skip-phases=addon/kube-proxy, there is no kube-proxy at all and Cilium serves the Services in eBPF. false: kube-proxy is there, Cilium sits on top
k8sServiceHost=<VIP> + k8sServicePort=6443 mandatory without kube-proxy: nothing provisions the apiserver ClusterIP any more, so the agent cannot bootstrap through kubernetes.default. We point at the keepalived VIP, not at cp1's own IP: the VIP survives losing cp1, and it is the address already baked into the certificates
l2announcements.enabled=true enables the controller that answers ARP; without it the CiliumL2AnnouncementPolicy is ignored
externalIPs.enabled=true support for Service externalIPs
envoy.enabled=false no need for Cilium's embedded Envoy: the lab uses the ../envoy-gateway/ controller, a separate component
hubble.* + bandwidthManager.enabled=true flow observability + bandwidth management (demos)

⚠️ What is deliberately absent. The Cilium docs' Talos page recommends cgroup.autoMount.enabled=false, cgroup.hostRoot=/sys/fs/cgroup and explicit securityContext.capabilities.* lists. Those are Talos workarounds and they are actively harmful on Debian: the chart mounts cgroup2 itself and computes the capabilities the agent needs. Do not copy them back in.

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 the host-only NIC, 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.
  • Host-only interface: the only NIC through which the host can reach the VMs. The manifest ships ^enp0s8$ as the default; cilium-up.sh rewrites it from HOSTONLY_IF in _out/cluster.env, so a box that names it eth1 works with no edit.
  • 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

# Agent-side diagnostics. ⚠️ the in-pod binary was RENAMED `cilium` -> `cilium-dbg` in
# v1.15: `... -- cilium status` no longer exists.
kubectl -n kube-system exec ds/cilium -- cilium-dbg status --verbose
kubectl -n kube-system exec ds/cilium -- cilium-dbg service list   # empty => no eBPF Services

With KUBE_PROXY_REPLACEMENT=true there is no kube-proxy DaemonSet in kube-system, and that is the expected state — cilium-dbg status must report KubeProxyReplacement: True.

🌐 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 is decided at bootstrap, not here. KUBE_PROXY_REPLACEMENT=true makes kubeadm/cluster-up.sh run kubeadm init --skip-phases=addon/kube-proxy; this script then reads the same value back from _out/cluster.env. Flipping only one of the two loses every Service in the cluster. Changing your mind means rebuilding: ./kubeadm/cluster-reset.sh then ./kubeadm/cluster-up.sh.
  • k8sServiceHost is not optional without kube-proxy. Nothing provisions the 10.96.0.1 apiserver ClusterIP any more, so an agent that only knows kubernetes.default never connects and stays in CrashLoopBackOff on Unable to contact k8s api-server.
  • Pod CIDR mismatch. ipam.mode=cluster-pool defaults to 10.0.0.0/8 regardless of the podSubnet given to kubeadm. Symptom: pods get 10.0.x.x addresses while kubectl get node -o jsonpath='{.spec.podCIDR}' says 10.244.x.0/24. Always pass POD_CIDR back (the script does).
  • 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.
  • The two objects do NOT share an apiVersion, and that asymmetry is deliberate — verified against the Cilium 1.20.0 docs: CiliumLoadBalancerIPPool is cilium.io/v2, while CiliumL2AnnouncementPolicy is still cilium.io/v2alpha1. Aligning them "for consistency" is the mistake to avoid: the pool silently stops being served on v2alpha1, and the Gateway goes back to <pending>. If a future Cilium promotes the policy to v2, the symptom is an apiVersion rejection at kubectl apply — check with kubectl get crd ciliuml2announcementpolicies.cilium.io -o jsonpath='{.spec.versions[*].name}'.

📚 References#

🐝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 (./kubeadm/cluster-up.sh, le défaut — CNI=none est équivalent ici) kubeadm init n'installe aucun CNI : c'est Cilium qui prend la place kubectl get nodesNotReady avant l'install, c'est normal
_out/cluster.env présent il porte les faits détectés que lit le script : HOSTONLY_IF, POD_CIDR, VIP, KUBE_PROXY_REPLACEMENT cat _out/cluster.env
Une interface host-only (en général enp0s8) source de l'annonce ARP et des tunnels VXLAN vagrant ssh k8s-cp1 -c 'ip -br a'
kubectl + helm, KUBECONFIG posé le script vérifie les binaires puis /readyz helm version

⚡ Installation#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> cilium     # <distro> = talos | kubeadm
./cilium/cilium-up.sh <distro>

Chart cilium/cilium 1.20.0, lu dans lab.env (CILIUM_VERSION) et surchargeable au coup par coup (CILIUM_VERSION=1.20.1 ./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.

🧬 Talos vs kubeadm#

C'est le composant le plus dépendant de la distribution de tout le dépôt.

Valeur Helm Talos kubeadm Pourquoi
ipam.mode kubernetes cluster-pool Sur Talos, le kube-controller-manager découpe déjà les podCIDR par node et Cilium les suit. Sur kubeadm, l'opérateur Cilium gère le pool (d'où clusterPoolIPv4PodCIDRList + clusterPoolIPv4MaskSize=24).
kubeProxyReplacement false (forcé) KUBE_PROXY_REPLACEMENT, défaut true Talos pose toujours kube-proxy. Sur kubeadm, kubeadm init a pu tourner avec --skip-phases=addon/kube-proxy : Cilium doit alors le remplacer en eBPF, et se tromper de valeur casse tous les Services (CoreDNS compris).
cgroup.autoMount.enabled + cgroup.hostRoot false + /sys/fs/cgroup (exigés) non posés Talos monte déjà cgroup2, et le pod ne peut pas remonter /sys/fs/cgroup (lecture seule). Sur Debian le chart s'en charge : forcer ces valeurs y serait nuisible.
securityContext.capabilities.* listes explicites (exigées) non posées Talos refuse le privileged implicite du chart.
devices enp0s8 eth1/enp0s8, détecté dans _out/cluster.env Sans épinglage, Cilium prend la carte NAT 10.0.2.15 — identique sur toutes les VM ⇒ trafic cross-node et DNS cassés.

Source : lib/profiles/<distro>.sh (CILIUM_IPAM_MODE, cilium_specific_sets(), KUBE_PROXY_REPLACEABLE, DEFAULT_HOSTONLY_IF).

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Vérifier le point de départ#

kubectl get nodes                 # NotReady partout : c'est NORMAL, il n'y a pas de CNI
kubectl get ds -A | grep -Ei 'cilium|flannel|calico'   # doit être VIDE (un seul CNI par cluster)

2. Relever les paramètres du lab#

# kubeadm : les FAITS sont dans _out/cluster.env (écrit par cluster-up.sh)
grep -E 'HOSTONLY_IF|POD_CIDR|KUBE_PROXY_REPLACEMENT' ../Vagrant-KubeADM/_out/cluster.env
# Talos : le CIDR pod est dans la config machine générée
grep -A2 podSubnets ../Vagrant-Talos/_out/controlplane.yaml

3. Ajouter le dépôt Helm#

helm repo add cilium https://helm.cilium.io/ && helm repo update cilium
helm search repo cilium/cilium --versions | head -3      # vérifier la dernière stable

4. Installer Cilium — la commande diffère selon la distribution#

Talos
helm upgrade --install cilium cilium/cilium -n kube-system --create-namespace \
  --version 1.20.0 \
  --set envoy.enabled=false \
  --set kubeProxyReplacement=false \
  --set k8sServiceHost=192.168.56.5 --set k8sServicePort=6443 \
  --set routingMode=tunnel --set tunnelProtocol=vxlan \
  --set ipam.mode=kubernetes \
  --set l2announcements.enabled=true --set externalIPs.enabled=true \
  --set hubble.enabled=true --set hubble.relay.enabled=true --set hubble.ui.enabled=true \
  --set bandwidthManager.enabled=true \
  --set devices=enp0s8 \
  --set cgroup.autoMount.enabled=false --set cgroup.hostRoot=/sys/fs/cgroup \
  --set securityContext.capabilities.ciliumAgent="{CHOWN,KILL,NET_ADMIN,NET_RAW,IPC_LOCK,SYS_ADMIN,SYS_RESOURCE,DAC_OVERRIDE,FOWNER,SETGID,SETUID}" \
  --set securityContext.capabilities.cleanCiliumState="{NET_ADMIN,SYS_ADMIN,SYS_RESOURCE}"
kubeadm
helm upgrade --install cilium cilium/cilium -n kube-system --create-namespace \
  --version 1.20.0 \
  --set envoy.enabled=false \
  --set kubeProxyReplacement=true \
  --set k8sServiceHost=192.168.56.5 --set k8sServicePort=6443 \
  --set routingMode=tunnel --set tunnelProtocol=vxlan \
  --set ipam.mode=cluster-pool \
  --set ipam.operator.clusterPoolIPv4PodCIDRList='{10.244.0.0/16}' \
  --set ipam.operator.clusterPoolIPv4MaskSize=24 \
  --set l2announcements.enabled=true --set externalIPs.enabled=true \
  --set hubble.enabled=true --set hubble.relay.enabled=true --set hubble.ui.enabled=true \
  --set bandwidthManager.enabled=true \
  --set devices=eth1        # ⚠️ la valeur DÉTECTÉE dans _out/cluster.env

5. Attendre que le CNI débloque les nodes#

kubectl wait --for=condition=Ready nodes --all --timeout=300s
kubectl -n kube-system get pods -l k8s-app=cilium -o wide

6. Poser le pool d'IP LoadBalancer + l'annonce L2 (ARP)#

C'est ce qui donne une vraie IP au Service du Gateway (le « cloud provider » du lab).

sed -e 's/192\.168\.56\.200/192.168.56.200/' \
    -e 's/enp0s8/eth1/' \
    cilium/cilium-l2.yml | kubectl apply -f -     # sur Talos : laisser enp0s8
kubectl get ciliumloadbalancerippool
kubectl get ciliuml2announcementpolicy

7. Vérifier#

kubectl -n kube-system exec ds/cilium -- cilium-dbg status --verbose | head -30
# kube-proxy remplacé ? (kubeadm avec KUBE_PROXY_REPLACEMENT=true)
kubectl -n kube-system get ds kube-proxy 2>&1 | tail -1

🔧 Ce que fait le script#

  1. Lit _out/cluster.env (puis lab.env en repli) : HOSTONLY_IF, POD_CIDR, VIP, KUBE_PROXY_REPLACEMENT — des faits détectés, pas devinés ;
  2. installe Cilium en Helm dans kube-system avec les valeurs ci-dessous ;
  3. attend condition=Ready sur tous les nodes (300 s max) — c'est le CNI qui les débloque ;
  4. applique cilium-l2.yml : pool d'IP LoadBalancer + politique d'annonce ARP, avec la plage et le nom de l'interface substitués.

Les --set qui comptent#

Réglage Pourquoi
devices=<HOSTONLY_IF> le point clé : épingle la carte host-only (lue dans _out/cluster.env, jamais codée en dur). 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. Il n'y a aucun routeur sur le réseau host-only : le routage natif exigerait une route statique par node côté VirtualBox, le tunnel supprime le problème
ipam.mode=cluster-pool + ipam.operator.clusterPoolIPv4PodCIDRList={<POD_CIDR>} + clusterPoolIPv4MaskSize=24 ⚠️ le piège : le défaut de cluster-pool est 10.0.0.0/8, sans aucun rapport avec le podSubnet déclaré à kubeadm. Si on ne repasse pas POD_CIDR explicitement, kubeadm et Cilium ne parlent pas du même réseau
kubeProxyReplacement=<KUBE_PROXY_REPLACEMENT> true (défaut) : kubeadm init a tourné avec --skip-phases=addon/kube-proxy, il n'y a aucun kube-proxy et Cilium sert les Services en eBPF. false : kube-proxy est là, Cilium se pose par-dessus
k8sServiceHost=<VIP> + k8sServicePort=6443 obligatoire sans kube-proxy : plus rien ne provisionne la ClusterIP de l'apiserver, l'agent ne peut donc pas s'amorcer via kubernetes.default. On vise la VIP keepalived, pas l'IP propre de cp1 : la VIP survit à la perte de cp1, et c'est l'adresse déjà figée dans les certificats
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
envoy.enabled=false pas besoin de l'Envoy embarqué de Cilium : le lab utilise le contrôleur ../envoy-gateway/, un composant distinct
hubble.* + bandwidthManager.enabled=true observabilité des flux + gestion de bande passante (démos)

⚠️ Ce qui est volontairement absent. La page « Talos » de la doc Cilium recommande cgroup.autoMount.enabled=false, cgroup.hostRoot=/sys/fs/cgroup et des listes explicites de securityContext.capabilities.*. Ce sont des contournements Talos, activement nuisibles sur Debian : le chart monte lui-même le cgroup2 et calcule les capabilities dont l'agent a besoin. Ne les remets pas.

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 la carte host-only, 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 host-only : la seule carte par laquelle l'hôte peut joindre les VMs. Le manifeste porte ^enp0s8$ comme défaut ; cilium-up.sh le réécrit depuis HOSTONLY_IF de _out/cluster.env, donc une box qui la nomme eth1 marche sans rien éditer.
  • 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

# Diagnostic côté agent. ⚠️ le binaire dans le pod a été RENOMMÉ `cilium` -> `cilium-dbg`
# en v1.15 : `... -- cilium status` n'existe plus.
kubectl -n kube-system exec ds/cilium -- cilium-dbg status --verbose
kubectl -n kube-system exec ds/cilium -- cilium-dbg service list   # vide => aucun Service eBPF

Avec KUBE_PROXY_REPLACEMENT=true il n'y a aucun DaemonSet kube-proxy dans kube-system, et c'est l'état attendu — cilium-dbg status doit afficher KubeProxyReplacement: True.

🌐 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.
  • Le remplacement de kube-proxy se décide au bootstrap, pas ici. KUBE_PROXY_REPLACEMENT=true fait lancer à kubeadm/cluster-up.sh un kubeadm init --skip-phases=addon/kube-proxy ; ce script relit ensuite la même valeur dans _out/cluster.env. N'en changer qu'un des deux fait perdre TOUS les Services du cluster. Changer d'avis = reconstruire : ./kubeadm/cluster-reset.sh puis ./kubeadm/cluster-up.sh.
  • k8sServiceHost n'est pas optionnel sans kube-proxy. Plus rien ne provisionne la ClusterIP 10.96.0.1 de l'apiserver : un agent qui ne connaît que kubernetes.default ne se connecte jamais et boucle en CrashLoopBackOff sur Unable to contact k8s api-server.
  • CIDR pod divergent. ipam.mode=cluster-pool part par défaut sur 10.0.0.0/8, quoi qu'on ait déclaré à kubeadm. Symptôme : les pods prennent des adresses 10.0.x.x alors que kubectl get node -o jsonpath='{.spec.podCIDR}' annonce 10.244.x.0/24. Il faut toujours repasser POD_CIDR (le script le fait).
  • 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.
  • Les deux objets ne partagent PAS la même apiVersion, et cette asymétrie est voulue — vérifiée sur la documentation Cilium 1.20.0 : CiliumLoadBalancerIPPool est en cilium.io/v2, tandis que CiliumL2AnnouncementPolicy est toujours en cilium.io/v2alpha1. Les aligner « par cohérence » est justement l'erreur à ne pas faire : le pool cesse silencieusement d'être servi en v2alpha1, et le Gateway repasse en <pending>. Si une future version de Cilium promeut la policy en v2, le symptôme est un rejet d'apiVersion au kubectl apply — à vérifier avec kubectl get crd ciliuml2announcementpolicies.cilium.io -o jsonpath='{.spec.versions[*].name}'.

📚 Références#

🐆calico/ — alternative CNI: pod network + NetworkPolicy, no LoadBalancer IPs

The lab's third CNI choice, next to flannel (bare-bones) 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 on its own — which is why ../metallb/ is installed alongside it, automatically. 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-playground/ layer of the lab depends on that VIP. So ../platform-up.sh no longer leaves you there: as soon as CNI != cilium it installs MetalLB right after this script, on the very same range and the same interface Cilium would have used. See 🌐 Making the UIs reachable.

Cilium or Calico?#

Capability Cilium (cilium/) Calico (this directory)
CNI (pod network, routing) ✅ VXLAN, host-only NIC 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) ❌ none of its own (BGP only) → metallb/, installed automatically
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 platform-up.sh chains everything too, MetalLB included

💡 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, then ./kubeadm/cluster-up.sh or ./talos/cluster-up.sh) kubeadm init never installs a pod network: 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 a cilium or flannel DaemonSet in any namespace
KUBE_PROXY_REPLACEMENT=false ⚠️ only Cilium can replace kube-proxy here. kubeadm/cluster-up.sh refuses the calico + true pair outright, and this script re-checks it: without kube-proxy and without a replacement, no ClusterIP answers at all kubectl -n kube-system get ds/kube-proxy
kubeadm podSubnet == IPPool CIDR otherwise the kubelet allocates pod IPs that Calico has not programmed grep POD_CIDR _out/cluster.env10.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#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> calico     # <distro> = talos | kubeadm
./calico/calico-up.sh <distro>

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 POD_CIDR from _out/cluster.env, else lab.env, else 10.244.0.0/16 IPPool CIDR — must stay equal to the kubeadm podSubnet
HOSTONLY_CIDR ${NETWORK}.0/24 only touch it if your host-only network is not a /24

🧬 Talos vs kubeadm#

The installation.yaml manifest is shared, but two of its settings do not have the same status on both distributions:

Setting Talos kubeadm
flexVolumePath: None MANDATORY: without it the operator mounts /usr/libexec/kubernetes/… as DirectoryOrCreate, and /usr is READ-ONLY ⇒ the calico-node pod never starts pure slim-down (FlexVolume has been deprecated since K8s 1.23 and is unused here)
kubeletVolumePluginPath: None (CSI off) MANDATORY (official Sidero guide "Deploy Calico CNI") slim-down: one DaemonSet less per node
privileged PodSecurity labels on tigera-operator required (cluster default baseline) intent documentation (no level enforced)
Guardrail forbidding KUBE_PROXY_REPLACEMENT=true moot (kube-proxy always installed) checked: Calico does not replace kube-proxy
Where the cluster pod CIDR is read _out/controlplane.yaml (podSubnets) _out/cluster.env (POD_CIDR)

Calico's eBPF dataplane stays off on both: it needs bpfNetworkBootstrap + FelixConfiguration, and it is broken on some Talos releases (siderolabs/talos#12221). For eBPF in this lab: use Cilium.

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. Guardrails (what the script checks before touching the cluster)#

kubectl get nodes                                        # NotReady: expected, no CNI yet
kubectl get ds -A | grep -Ei 'cilium|flannel'            # must be EMPTY
kubectl -n kube-system get ds kube-proxy                 # MUST exist (Calico won't replace it)

2. Operator namespace (PodSecurity labels)#

kubectl apply -f calico/namespace.yaml
kubectl get ns tigera-operator --show-labels

3. Tigera operator chart — all 4 chart CRs disabled#

The chart ships no crds/ directory: the operator creates the operator.tigera.io CRDs when it starts. Any CR rendered by Helm would therefore fail on a fresh cluster ("no matches for kind").

helm repo add projectcalico https://docs.tigera.io/calico/charts && helm repo update projectcalico
helm upgrade --install calico projectcalico/tigera-operator \
  --namespace tigera-operator --create-namespace \
  --version v3.32.1 \
  --set installation.enabled=false --set apiServer.enabled=false \
  --set goldmane.enabled=false --set whisker.enabled=false
kubectl -n tigera-operator rollout status deploy/tigera-operator --timeout=300s

4. Wait for the operator-created CRDs#

kubectl wait --for=condition=Established crd/installations.operator.tigera.io --timeout=60s
kubectl wait --for=condition=Established crd/apiservers.operator.tigera.io  --timeout=60s

5. Apply the Installation + APIServer CRs#

# The versioned CIDRs are the lab defaults: adjust if your lab.env differs
sed -e 's#192\.168\.56\.0/24#192.168.56.0/24#g' \
    -e 's#10\.244\.0\.0/16#10.244.0.0/16#g' \
    calico/installation.yaml | kubectl apply -f -
kubectl apply -f calico/apiserver.yaml

6. Wait for calico-node, then for the nodes#

kubectl -n calico-system rollout status daemonset/calico-node --timeout=600s
kubectl wait --for=condition=Ready nodes --all --timeout=300s
kubectl get tigerastatus

7. Verify — and understand what is MISSING#

kubectl get ippools.projectcalico.org         # through the calico-apiserver, no calicoctl
kubectl -n envoy-gateway-system get svc       # EXTERNAL-IP <pending>: EXPECTED with Calico

⚠️ Calico announces no LoadBalancer Service IP (BGP only). <pending> at this exact point is therefore normal: it is ../metallb/, installed by platform-up.sh right after this script, that fills the address in — see the 🌐 section.

🔧 What the script does#

  1. Guardrails: binaries, /readyz, refusal if another CNI is already there, refusal if kube-proxy is missing (KUBE_PROXY_REPLACEMENT=true), and refusal if POD_CIDR diverges from the POD_CIDR recorded in _out/cluster.env.
  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. kubeadm enforces nothing cluster-wide by default, so this is insurance rather than a hard requirement — but it is what keeps the operator working on a hardened cluster (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 kubeadm podSubnet
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 kube-proxy installed by kubeadm init — hence the mandatory KUBE_PROXY_REPLACEMENT=false
calicoNetwork.mtu 1450 1500 (host-only) − 50 (IPv4 VXLAN headers)
kubeletVolumePluginPath None lab slimming: disables the Calico CSI driver (and its per-node csi-node-driver DaemonSet). It only serves flow-log ephemeral volumes, unused here
flexVolumePath None lab slimming: without it the operator adds a flexvol-driver init container mounting /usr/libexec/kubernetes/kubelet-plugins/volume/exec/. FlexVolume has been deprecated since Kubernetes 1.23 and only feeds Dikastes (L7 policy), unused here

✅ 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 — but you no longer have to do anything about it. Both steps that used to be manual are now handled by ../platform-up.sh at step [1/4], right after this script, as soon as CNI != cilium:

1. MetalLB is installed in L2 mode, by ../metallb/metallb-up.sh, on the same range Cilium uses (192.168.56.200192.168.56.230, with the first IP going to main-gateway), on the same host-only interface and from the workers only. It reads the same LB_POOL_START / LB_POOL_END / HOSTONLY_IF keys of lab.env, so the wildcard DNS record keeps pointing at the same address whichever CNI you picked.

2. The Cilium-specific loadBalancerClass is removed. ../envoy-gateway/Envoy-Proxy.yml pins, on line 13:

        loadBalancerClass: io.cilium/l2-announcer

platform-up.sh strips that line before applying the manifest when CNI != cilium.

⚠️ It matters, and it is the #1 false lead here. A loadBalancerClass tells Kubernetes "only this controller may handle this Service": left in place, MetalLB ignores the Service and the IP stays <pending> even with a perfectly valid pool. If you apply Envoy-Proxy.yml by hand rather than through platform-up.sh, delete the line yourself.

Result, with no extra step:

./platform-up.sh <distro>                                 # CNI=calico → Calico + MetalLB
kubectl -n envoy-gateway-system get svc                   # EXTERNAL-IP = 192.168.56.200
kubectl get servicel2status -A -o wide                    # which worker announces it

Installing it on its own — for a repair, or if you had opted out with METALLB=false:

./install.sh <distro> metallb

⚠️ Never on a Cilium cluster. Two announcers on one range means two nodes answering ARP for .200. metallb-up.sh refuses outright — see ../metallb/README.md.

⚠️ 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=<host-only> (Cilium).
  • A baseline PodSecurity level 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. kubeadm enforces nothing cluster-wide, so this does not bite out of the box — but it does on any cluster with a hardened default. helm --create-namespace sets no PSS label, so without namespace.yaml the Deployment would be created while 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 ≠ kubeadm podSubnet = silently broken pod network. The script refuses to continue if it detects the mismatch in _out/cluster.env, 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: ./kubeadm/cluster-reset.sh (or vagrant destroy) → CNI=calico in lab.env./kubeadm/cluster-up.sh./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 MetalLB is installed — and it only bites when Envoy-Proxy.yml was applied by hand, since platform-up.sh strips the line for you.
  • eBPF dataplane: tempting, ruled out. It would require bpfNetworkBootstrap: Enabled, kubeProxyManagement: Enabled and a FelixConfiguration (cgroupV2Path), and it would take over kube-proxy — exactly what KUBE_PROXY_REPLACEMENT=false says is not happening here. Too many moving parts for the lab's "comparison" CNI: if you want eBPF, take Cilium.
  • 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 right after this script; a problem only once MetalLB has run 🌐 section, then ../metallb/README.md
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 (./kubeadm/cluster-reset.shCNI=cilium./kubeadm/cluster-up.sh./platform-up.sh).

📚 References#

🐆calico/ — CNI alternatif : réseau pod + NetworkPolicy, sans IP LoadBalancer

Troisième choix de CNI du lab, à côté de flannel (minimaliste) 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 par lui-même — c'est pour ça que ../metallb/ est installé à côté, automatiquement. 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-playground/ du lab dépend de ce VIP. Aussi ../platform-up.sh ne te laisse plus là : dès que CNI != cilium, il installe MetalLB juste après ce script, sur exactement la même plage et la même interface que Cilium aurait utilisées. Voir 🌐 Rendre les UI joignables.

Cilium ou Calico ?#

Capacité Cilium (cilium/) Calico (ce dossier)
CNI (réseau pod, routage) ✅ VXLAN, interface host-only é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) ❌ aucune en propre (BGP uniquement) → metallb/, installé automatiquement
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 platform-up.sh enchaîne tout aussi, MetalLB compris

💡 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, puis ./kubeadm/cluster-up.sh ou ./talos/cluster-up.sh) kubeadm init n'installe jamais de réseau pod : 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 un DaemonSet cilium ou flannel dans n'importe quel namespace
KUBE_PROXY_REPLACEMENT=false ⚠️ seul Cilium sait remplacer kube-proxy ici. kubeadm/cluster-up.sh refuse net le couple calico + true, et ce script le revérifie : sans kube-proxy ET sans remplaçant, plus aucune ClusterIP ne répond kubectl -n kube-system get ds/kube-proxy
podSubnet de kubeadm == CIDR de l'IPPool sinon kubelet alloue des IP de pod que Calico n'a pas programmées grep POD_CIDR _out/cluster.env10.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#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> calico     # <distro> = talos | kubeadm
./calico/calico-up.sh <distro>

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 POD_CIDR de _out/cluster.env, sinon lab.env, sinon 10.244.0.0/16 CIDR de l'IPPool — doit rester égal au podSubnet de kubeadm
HOSTONLY_CIDR ${NETWORK}.0/24 à ne toucher que si ton host-only n'est pas un /24

🧬 Talos vs kubeadm#

Le manifeste installation.yaml est commun, mais deux de ses réglages n'ont pas le même statut selon la distribution :

Réglage Talos kubeadm
flexVolumePath: None OBLIGATOIRE : sans lui l'opérateur monte /usr/libexec/kubernetes/… en DirectoryOrCreate, or /usr est en LECTURE SEULE ⇒ le pod calico-node ne démarre jamais simple allègement (FlexVolume est déprécié depuis K8s 1.23 et inutilisé ici)
kubeletVolumePluginPath: None (CSI coupé) OBLIGATOIRE (guide officiel Sidero « Deploy Calico CNI ») allègement : un DaemonSet de moins par node
Labels PodSecurity privileged sur tigera-operator indispensables (défaut cluster baseline) documentation d'intention (aucun niveau appliqué)
Garde-fou KUBE_PROXY_REPLACEMENT=true interdit sans objet (kube-proxy toujours posé) vérifié : Calico ne remplace pas kube-proxy
Détection du CIDR pod du cluster _out/controlplane.yaml (podSubnets) _out/cluster.env (POD_CIDR)

Le dataplane eBPF de Calico reste coupé sur les deux : il exige bpfNetworkBootstrap + FelixConfiguration, et il est cassé sur certaines versions de Talos (siderolabs/talos#12221). Pour de l'eBPF dans ce lab : Cilium.

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Garde-fous (ce que le script vérifie avant de toucher au cluster)#

kubectl get nodes                                        # NotReady : normal, pas de CNI
kubectl get ds -A | grep -Ei 'cilium|flannel'            # doit être VIDE
kubectl -n kube-system get ds kube-proxy                 # DOIT exister (Calico ne le remplace pas)

2. Namespace de l'opérateur (labels PodSecurity)#

kubectl apply -f calico/namespace.yaml
kubectl get ns tigera-operator --show-labels

3. Chart de l'opérateur Tigera — les 4 CR du chart sont coupées#

Le chart ne livre pas de dossier crds/ : c'est l'opérateur qui crée les CRD operator.tigera.io à son démarrage. Toute CR rendue par Helm échouerait donc sur un cluster neuf (« no matches for kind »).

helm repo add projectcalico https://docs.tigera.io/calico/charts && helm repo update projectcalico
helm upgrade --install calico projectcalico/tigera-operator \
  --namespace tigera-operator --create-namespace \
  --version v3.32.1 \
  --set installation.enabled=false --set apiServer.enabled=false \
  --set goldmane.enabled=false --set whisker.enabled=false
kubectl -n tigera-operator rollout status deploy/tigera-operator --timeout=300s

4. Attendre les CRD créées par l'opérateur#

kubectl wait --for=condition=Established crd/installations.operator.tigera.io --timeout=60s
kubectl wait --for=condition=Established crd/apiservers.operator.tigera.io  --timeout=60s

5. Appliquer les CR Installation + APIServer#

# Les CIDR versionnés sont les défauts du lab : adapte-les si ton lab.env diffère
sed -e 's#192\.168\.56\.0/24#192.168.56.0/24#g' \
    -e 's#10\.244\.0\.0/16#10.244.0.0/16#g' \
    calico/installation.yaml | kubectl apply -f -
kubectl apply -f calico/apiserver.yaml

6. Attendre calico-node puis les nodes Ready#

kubectl -n calico-system rollout status daemonset/calico-node --timeout=600s
kubectl wait --for=condition=Ready nodes --all --timeout=300s
kubectl get tigerastatus

7. Vérifier — et comprendre ce qui MANQUE#

kubectl get ippools.projectcalico.org         # via le calico-apiserver, sans calicoctl
kubectl -n envoy-gateway-system get svc       # EXTERNAL-IP <pending> : ATTENDU avec Calico

⚠️ Calico n'annonce aucune IP de Service LoadBalancer (BGP uniquement). Un <pending> à cet endroit précis est donc normal : c'est ../metallb/, installé par platform-up.sh juste après ce script, qui remplit l'adresse — cf. la section 🌐.

🔧 Ce que fait le script#

  1. Garde-fous : binaires, /readyz, refus si un autre CNI est déjà là, refus si kube-proxy est absent (KUBE_PROXY_REPLACEMENT=true), et refus si POD_CIDR diverge du POD_CIDR enregistré dans _out/cluster.env.
  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. Sur kubeadm rien n'est appliqué au niveau cluster par défaut : c'est donc une assurance plutôt qu'une obligation — mais c'est elle qui garde l'opérateur fonctionnel sur un cluster durci (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 au podSubnet de kubeadm
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 posé par kubeadm init — d'où le KUBE_PROXY_REPLACEMENT=false obligatoire
calicoNetwork.mtu 1450 1500 (host-only) − 50 (entêtes VXLAN IPv4)
kubeletVolumePluginPath None allègement du lab : coupe le driver CSI Calico (et son DaemonSet csi-node-driver par node). Il ne sert qu'aux volumes éphémères de flow logs, inutilisés ici
flexVolumePath None allègement du lab : sans ça l'opérateur ajoute un init-container flexvol-driver qui monte /usr/libexec/kubernetes/kubelet-plugins/volume/exec/. FlexVolume est déprécié depuis Kubernetes 1.23 et n'alimente ici que Dikastes (policy L7), inutilisé

✅ 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 — mais tu n'as plus rien à faire pour autant. Les deux étapes autrefois manuelles sont désormais prises en charge par ../platform-up.sh à l'étape [1/4], juste après ce script, dès que CNI != cilium :

1. MetalLB est installé en mode L2, par ../metallb/metallb-up.sh, sur la même plage que celle qu'utilise Cilium (192.168.56.200192.168.56.230, la première IP revenant à main-gateway), sur la même interface host-only et depuis les workers uniquement. Il lit les mêmes clés LB_POOL_START / LB_POOL_END / HOSTONLY_IF de lab.env : l'enregistrement DNS wildcard continue donc de pointer vers la même adresse, quel que soit le CNI retenu.

2. La loadBalancerClass spécifique à Cilium est retirée. ../envoy-gateway/Envoy-Proxy.yml épingle, ligne 13 :

        loadBalancerClass: io.cilium/l2-announcer

platform-up.sh supprime cette ligne avant d'appliquer le manifeste quand CNI != cilium.

⚠️ Ça compte, et c'est la fausse piste n°1 ici. Une loadBalancerClass dit à Kubernetes « seul ce contrôleur a le droit de traiter ce Service » : laissée en place, MetalLB ignore le Service et l'IP reste <pending> même avec un pool parfaitement valide. Si tu appliques Envoy-Proxy.yml à la main plutôt que via platform-up.sh, retire la ligne toi-même.

Résultat, sans étape supplémentaire :

./platform-up.sh <distro>                                 # CNI=calico → Calico + MetalLB
kubectl -n envoy-gateway-system get svc                   # EXTERNAL-IP = 192.168.56.200
kubectl get servicel2status -A -o wide                    # quel worker l'annonce

L'installer seul — pour une réparation, ou si tu t'étais exclu avec METALLB=false :

./install.sh <distro> metallb

⚠️ Jamais sur un cluster Cilium. Deux annonceurs sur une plage, ce sont deux nodes qui répondent à l'ARP pour .200. metallb-up.sh refuse net — cf. ../metallb/LISEZ-MOI.md.

⚠️ 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=<host-only> (Cilium).
  • Un niveau 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. kubeadm n'applique rien au niveau cluster : ça ne mord donc pas d'origine — mais ça mord sur tout cluster au défaut durci. Le helm --create-namespace ne pose aucun label PSS : sans namespace.yaml, le Deployment serait bien créé mais le ReplicaSet n'arriverait à 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'IPPoolpodSubnet de kubeadm = réseau pod silencieusement cassé. Le script refuse de continuer s'il détecte l'écart dans _out/cluster.env, 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 : ./kubeadm/cluster-reset.sh (Talos : vagrant destroy) → CNI=calico dans lab.env./kubeadm/cluster-up.sh (ou ./talos/cluster-up.sh) → ./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 l'installation de MetalLB — et ça ne mord que si Envoy-Proxy.yml a été appliqué à la main, puisque platform-up.sh retire la ligne pour toi.
  • Dataplane eBPF : tentant, écarté. Il exigerait bpfNetworkBootstrap: Enabled, kubeProxyManagement: Enabled et une FelixConfiguration (cgroupV2Path), et il prendrait la main sur kube-proxy — exactement ce que KUBE_PROXY_REPLACEMENT=false dit qu'on ne fait pas ici. Trop de pièces mobiles pour le CNI « de comparaison » du lab : si on veut de l'eBPF, on prend Cilium.
  • 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 juste après ce script ; un problème seulement une fois MetalLB passé section 🌐, puis ../metallb/LISEZ-MOI.md
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 (./kubeadm/cluster-reset.shCNI=cilium./kubeadm/cluster-up.sh./platform-up.sh).

📚 Références#

📢metallb/ — LoadBalancer IPs and L2 announcement, when the CNI is not Cilium

The missing cloud provider. Cilium is the only CNI of this lab that hands type: LoadBalancer Services a real IP and announces it over ARP. With Calico, flannel or CNI=none, nothing does — the Envoy Gateway Service stays at EXTERNAL-IP <pending> and no lab UI is reachable. MetalLB in layer 2 mode fills exactly that hole, on the same range, the same interface and the same nodes as Cilium would.

🎯 Purpose#

What MetalLB does here#

  • Allocates an IP from the host-only range 192.168.56.200-230 to every type: LoadBalancer Service (the controller Deployment).
  • Announces it over ARP from a worker node, so the host — and anything routed to the host, Tailscale included — can reach it (the speaker DaemonSet).
  • Nothing else. MetalLB is not a CNI: it needs a working pod network to run.

The setup in one sentence#

metallb/metallb-up.sh reads the same lab.env keys as Cilium and lays down the same two objects, translated into MetalLB's API — so switching CNI changes the CNI, not the address the *.<LAB_DOMAIN> wildcard DNS record points at.

lab.env key Cilium object MetalLB object
LB_POOL_START / LB_POOL_END CiliumLoadBalancerIPPool (start/stop) IPAddressPool (addresses: ["start-stop"])
HOSTONLY_IF CiliumL2AnnouncementPolicy.interfaces (a regex, ^enp0s8$) L2Advertisement.interfaces (a plain name, enp0s8)
— (a lab choice) nodeSelector: control-plane DoesNotExist nodeSelectors: the same expression

The first IP of the range goes to main-gateway in both cases: 192.168.56.200.

When is it installed?#

../platform-up.sh decides at step [1/4], right after the CNI:

CNI in lab.env L2 announcer MetalLB installed?
cilium (the lab default) Cilium itself never — see ⚠️ Pitfalls
calico MetalLB
flannel MetalLB
none MetalLB ✅ (once your own CNI has made the nodes Ready)
any of the above + METALLB=false none ❌ — the Gateway stays <pending>

📋 Prerequisites#

Prerequisite Why Check
CNI != cilium Cilium already announces these IPs; two announcers on one range is an ARP conflict sed -n 's/^CNI=//p' lab.env
A working CNI, nodes Ready MetalLB is an ordinary workload: with no pod network the controller never gets an IP kubectl get nodes → all Ready
kube-proxy present MetalLB does not replace it (and KUBE_PROXY_REPLACEMENT=true implies CNI=cilium anyway) kubectl -n kube-system get ds kube-proxy
A host-only interface (enp0s8, or eth1 on some kubeadm boxes) source of the ARP announcement — never the NAT card cat _out/cluster.env (kubeadm)
kubectl + helm, KUBECONFIG set the script checks the binaries, then /readyz helm version

⚡ Install#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

In practice you do not run this by hand: ../platform-up.sh calls it at step [1/4] as soon as CNI != cilium. The direct call is there for a repair or for training:

./install.sh <distro> metallb      # <distro> = talos | kubeadm
./metallb/metallb-up.sh <distro>

Chart metallb/metallb 0.16.1, read from lab.env (METALLB_VERSION) and overridable per run (METALLB_VERSION=0.16.0 ./metallb/metallb-up.sh). Idempotent (helm upgrade --install + kubectl apply).

ℹ️ metallb is deliberately excluded from ./install.sh <distro> all: on the default CNI=cilium it refuses to install, and an unconditional all would always stop right there. platform installs it when — and only when — the CNI calls for it.

🧬 Talos vs kubeadm#

The announcement itself is identical: same chart, same pool, same interface, same node selection. One thing genuinely differs, and it is admission.

Point Talos kubeadm Consequence
PodSecurity default baseline enforced cluster-wide no level enforced The speaker runs on hostNetwork and adds NET_RAWbaseline forbids both. namespace.yaml is therefore mandatory on Talos and documentation on kubeadm.
Host-only interface always enp0s8 eth1 or enp0s8 depending on the box → detected into _out/cluster.env HOSTONLY_IF covers both; the script substitutes it into metallb-l2.yml.
kube-proxy always installed by Talos optional, but KUBE_PROXY_REPLACEMENT=true forces CNI=cilium So MetalLB always finds a kube-proxy in front of it, on both labs.
Likely CNI next to it calico, or flannel pre-installed at bootstrap calico, or flannel installed by platform-up.sh No effect on MetalLB: it only ever sees Ready nodes and Services.

ℹ️ No profile variable was added for this component: nothing here reads lib/profiles/<distro>.sh beyond DEFAULT_HOSTONLY_IF, which already existed. The PodSecurity divergence is carried by a namespace manifest applied in both cases — the repository rule: label the namespace everywhere, even where it unlocks nothing today.

🎓 Guided walkthrough (step by step)#

The same thing as the script, one command at a time. Set your variables first:

export KUBECONFIG="$PWD/kubeconfig"           # from the lab root
LB_POOL_START=$(sed -n 's/^LB_POOL_START=//p' lab.env | head -1 | tr -d ' "')
LB_POOL_END=$(sed -n 's/^LB_POOL_END=//p'     lab.env | head -1 | tr -d ' "')
HOSTONLY_IF=$(sed -n 's/^HOSTONLY_IF=//p' _out/cluster.env | head -1)   # kubeadm
HOSTONLY_IF=${HOSTONLY_IF:-enp0s8}                                      # Talos
echo "$LB_POOL_START-$LB_POOL_END on $HOSTONLY_IF"

1. Check the starting point#

kubectl get nodes                         # all Ready — the CNI is already there
kubectl get ciliuml2announcementpolicies.cilium.io 2>/dev/null   # MUST be empty / no CRD
kubectl -n envoy-gateway-system get svc   # EXTERNAL-IP <pending>, if the Gateway is up

2. The namespace, before the chart#

kubectl apply -f metallb/namespace.yaml
kubectl get ns metallb-system --show-labels     # pod-security.kubernetes.io/enforce=privileged

3. Add the Helm repository#

helm repo add metallb https://metallb.github.io/metallb
helm repo update metallb

4. Install MetalLB — L2 only, no FRR#

helm upgrade --install metallb metallb/metallb \
  --namespace metallb-system \
  --version 0.16.1 \
  --set frrk8s.enabled=false \
  --set speaker.frr.enabled=false

5. Wait for the controller and the speakers#

kubectl -n metallb-system rollout status deploy/metallb-controller --timeout=300s
kubectl -n metallb-system rollout status daemonset/metallb-speaker --timeout=300s

6. Apply the pool + the L2 announcement#

# The versioned values are the lab defaults: substitute yours, exactly as the script does
sed -e "s/192\.168\.56\.200/${LB_POOL_START}/g" \
    -e "s/192\.168\.56\.230/${LB_POOL_END}/g" \
    -e "s/enp0s8/${HOSTONLY_IF}/g" \
    metallb/metallb-l2.yml | kubectl apply -f -

⚠️ If this fails with connection refused or an x509 error on metallb-webhook-service, you are simply early: the controller injects its own certificate into the ValidatingWebhookConfiguration, which takes a few seconds after the rollout. Retry. That is exactly what the script's loop does.

7. Verify#

kubectl -n metallb-system get ipaddresspool,l2advertisement
kubectl -n envoy-gateway-system get svc          # EXTERNAL-IP = 192.168.56.200
kubectl get servicel2status -A -o wide           # which node announces which IP

🔧 What the script does#

Step What Why it is not just a helm install
guard rails refuses if Cilium announces, or if CNI=cilium; refuses if no node is Ready two ARP announcers on one range is the hardest failure of this lab to read; and MetalLB on a CNI-less cluster hangs Pending for 5 min before saying anything
[1/3] namespace.yaml then the chart helm --create-namespace sets no PodSecurity label — and its absence fails silently (see below)
[2/3] rollout status on the controller and the DaemonSet the controller allocates, the speakers announce: an IP with no speaker is assigned and unreachable
[3/3] metallb-l2.yml rendered, applied with retry the validating webhook is served by the controller and takes a few seconds to present a trusted certificate

Files#

File Role
metallb-up.sh the all-in-one, idempotent install
namespace.yaml metallb-system + the three privileged PodSecurity labels
metallb-l2.yml IPAddressPool + L2Advertisement — the strict mirror of ../cilium/cilium-l2.yml

The Helm settings that matter#

--set Value Why
frrk8s.enabled false Since chart 0.15 the FRR-K8s subchart is enabled by default: a full FRR routing daemon on every node, for BGP. This lab announces over L2 only (no peer router on a VirtualBox host-only network) — pure dead weight.
speaker.frr.enabled false The other, deprecated, way to get FRR into the speaker pod. The chart refuses both at once, so both are set explicitly rather than trusting which one defaults to false in the version of the day.

Everything else is left at the chart default on purpose — in particular speaker.tolerateMaster=true, which runs a speaker on the control planes too. That is harmless: what decides who announces is the nodeSelectors of the L2Advertisement, exactly like the Cilium policy.

metallb-l2.yml — two objects#

  • IPAddressPool — the range, written as a single start-stop string (Cilium uses two fields). autoAssign: true: the Gateway asks for no particular address and gets the first free one.
  • L2Advertisement — who announces, and on which card. ipAddressPools names the pool (without it the advertisement would cover every pool), interfaces pins the host-only card and nodeSelectors keeps the control planes out.

✅ Verify#

kubectl -n metallb-system get pods                      # 1 controller + 1 speaker per node
kubectl -n metallb-system get ipaddresspool -o wide     # the range
kubectl -n metallb-system get l2advertisement -o yaml | grep -A4 interfaces

# The Gateway must have taken the FIRST IP of the pool
kubectl -n envoy-gateway-system get svc

# Who is announcing what (MetalLB's equivalent of Cilium's leases)
kubectl get servicel2status -A -o wide

And above all, the IP must answer — which no kubectl get proves:

GWIP=$(kubectl -n envoy-gateway-system get svc \
  -o jsonpath='{.items[?(@.spec.type=="LoadBalancer")].status.loadBalancer.ingress[0].ip}')
curl -s -o /dev/null -w '%{http_code}\n' "http://$GWIP/"   # 404 = Envoy answers (no route yet)
ip neigh show "$GWIP"                                      # ARP resolved = L2 announcement OK

⚠️ Never test that IP with ping. No interface really carries the address: the elected speaker only answers ARP to attract the traffic, then the node forwards it. ICMP to the VIP therefore gets nothing, while ping on a node (.101) works — a very convincing false negative. The proof of the announcement is the ARP entry resolving to a worker's MAC:

ip neigh flush "$GWIP"; curl -s -o /dev/null --max-time 5 "http://$GWIP/"
ip neigh show "$GWIP"     # lladdr = MAC of the elected worker

🧪 Scenario — speaker failover#

The demo that justifies the component: the announcement survives losing a node.

kubectl get servicel2status -A -o wide          # note the announcing node, e.g. k8s-w2
ip neigh show "$GWIP"                           # note the MAC

# Take that node's speaker away
kubectl -n metallb-system delete pod -l app.kubernetes.io/component=speaker \
  --field-selector spec.nodeName=k8s-w2

# A few seconds later, another worker has taken over
kubectl get servicel2status -A -o wide
ip neigh flush "$GWIP"; curl -s -o /dev/null -w '%{http_code}\n' "http://$GWIP/"
ip neigh show "$GWIP"                           # a DIFFERENT MAC — same IP

The few seconds of downtime are the re-ARP: expected, not an incident.

🚑 Troubleshooting#

Symptom Likely cause What to do
The script fails on "Cilium already announces" you are on CNI=cilium — the lab default that is the guard rail, not a bug: Cilium already does this. See ⚠️ Pitfalls
The script fails on "no Ready node" no CNI installed yet install the CNI first — MetalLB is not one
EXTERNAL-IP stays <pending> with a valid pool the Service still carries loadBalancerClass: io.cilium/l2-announcer platform-up.sh strips that line when CNI != cilium; check kubectl -n envoy-gateway-system get svc -o yaml | grep -i loadbalancerclass
EXTERNAL-IP set but nothing answers zero speaker pod → PodSecurity refused the DaemonSet kubectl -n metallb-system describe ds/metallb-speaker, then apply namespace.yaml and rollout restart
Applying the pool fails with connection refused / x509 the controller's webhook is not serving yet wait 10 s and retry — the script loops for 150 s
ARP resolves to a control plane the nodeSelectors were dropped or the label differs kubectl -n metallb-system get l2advertisement -o yaml
ARP does not resolve at all announcement on the NAT card, or interfaces written as a regex kubectl -n metallb-system get l2advertisement -o yaml — it must read enp0s8, not ^enp0s8$
no available IPs in the controller logs pool exhausted (31 addresses) or two Services asking for a fixed IP kubectl -n metallb-system logs deploy/metallb-controller

⚠️ Pitfalls#

  • Never MetalLB and Cilium's L2 announcement. Two speakers answering ARP for 192.168.56.200 make the host's ARP cache flap between two MACs: the entry point works "one time in two", and nothing in any log says so. The script refuses on two independent signals (the live cilium.io objects, and CNI resolving to cilium) — do not work around it, the right move is to pick one CNI.
  • interfaces: is a list of plain names, not regexes. Cilium's policy takes ^enp0s8$; MetalLB takes enp0s8. Copying the Cilium syntax across matches no interface and the announcement is dropped in silence: pool valid, IP assigned, ARP mute.
  • loadBalancerClass beats any pool. As long as ../envoy-gateway/Envoy-Proxy.yml pins loadBalancerClass: io.cilium/l2-announcer, MetalLB ignores the Service — a loadBalancerClass means "only this controller may serve it". platform-up.sh removes the line when CNI != cilium; applying that manifest by hand does not.
  • The privileged labels are not cosmetic, and their absence lies to you. The controller is not privileged: it starts, allocates, and writes a perfectly normal EXTERNAL-IP into the Service. Only the speaker is refused — so you get an address that looks assigned and answers nothing. Always cross-check get svc with kubectl -n metallb-system get pods.
  • WORKERS=0 leaves nobody to announce. The nodeSelectors keep control planes out, on purpose. On a lab with no worker, remove them from metallb-l2.yml — otherwise the pool is valid and no one announces it.
  • One node per IP, not load balancing. Like CiliumL2AnnouncementPolicy, L2 mode elects a single speaker per address: all the VIP traffic enters through that node, then kube-proxy spreads it across the endpoints. A failover takes a few seconds (the time to re-ARP).
  • kube-proxy in IPVS mode needs strictARP: true. Neither lab uses IPVS (both are in iptables mode), so this does not bite here — but it is the first thing to check if you take this component to another cluster: in IPVS mode without strictARP, every node answers ARP for the VIP.
  • Changing CNI is not a live switch. Going from Cilium to Calico means a rebuilt cluster (./kubeadm/cluster-reset.sh, or vagrant destroy), not a helm uninstall. MetalLB only makes sense on a cluster bootstrapped with the right CNI from the start.

🧹 Uninstall#

kubectl -n metallb-system delete -f metallb/metallb-l2.yml    # stop announcing first
helm uninstall metallb -n metallb-system
kubectl delete ns metallb-system

⚠️ Every LoadBalancer Service goes back to EXTERNAL-IP <pending> and every UI becomes unreachable — the Gateway included. The CRDs are removed with the chart, so any leftover IPAddressPool disappears with them.

📚 References#

📢metallb/ — IP LoadBalancer et annonce L2, quand le CNI n'est pas Cilium

Le cloud provider manquant. Cilium est le seul CNI de ce lab qui attribue une vraie IP aux Services type: LoadBalancer et l'annonce en ARP. Avec Calico, flannel ou CNI=none, personne ne le fait — le Service de l'Envoy Gateway reste en EXTERNAL-IP <pending> et aucune UI du lab n'est joignable. MetalLB en mode layer 2 comble exactement ce trou, sur la même plage, la même interface et les mêmes nodes que Cilium.

🎯 À quoi ça sert#

Ce que MetalLB fait ici#

  • Attribue une IP de la plage host-only 192.168.56.200-230 à chaque Service type: LoadBalancer (le Deployment controller).
  • L'annonce en ARP depuis un node worker, pour que l'hôte — et tout ce qui est routé vers l'hôte, Tailscale compris — puisse l'atteindre (le DaemonSet speaker).
  • Rien d'autre. MetalLB n'est pas un CNI : il lui faut un réseau pod fonctionnel pour tourner.

Le montage en une phrase#

metallb/metallb-up.sh lit les mêmes clés de lab.env que Cilium et pose les deux mêmes objets, traduits dans l'API de MetalLB — pour que changer de CNI change le CNI, pas l'adresse vers laquelle pointe l'enregistrement DNS wildcard *.<LAB_DOMAIN>.

Clé de lab.env Objet Cilium Objet MetalLB
LB_POOL_START / LB_POOL_END CiliumLoadBalancerIPPool (start/stop) IPAddressPool (addresses: ["start-stop"])
HOSTONLY_IF CiliumL2AnnouncementPolicy.interfaces (une regex, ^enp0s8$) L2Advertisement.interfaces (un nom brut, enp0s8)
— (un choix du lab) nodeSelector : control-plane DoesNotExist nodeSelectors : la même expression

La première IP de la plage revient à main-gateway dans les deux cas : 192.168.56.200.

Quand est-il installé ?#

../platform-up.sh tranche à l'étape [1/4], juste après le CNI :

CNI dans lab.env Annonceur L2 MetalLB installé ?
cilium (le défaut du lab) Cilium lui-même jamais — voir ⚠️ Pièges
calico MetalLB
flannel MetalLB
none MetalLB ✅ (une fois ton propre CNI passé, les nodes Ready)
n'importe lequel ci-dessus + METALLB=false aucun ❌ — la Gateway reste <pending>

📋 Prérequis#

Prérequis Pourquoi Vérifier
CNI != cilium Cilium annonce déjà ces IP ; deux annonceurs sur une plage, c'est un conflit ARP sed -n 's/^CNI=//p' lab.env
Un CNI fonctionnel, nodes Ready MetalLB est une charge de travail ordinaire : sans réseau pod le controller n'obtient jamais d'IP kubectl get nodes → tous Ready
kube-proxy présent MetalLB ne le remplace pas (et KUBE_PROXY_REPLACEMENT=true implique CNI=cilium de toute façon) kubectl -n kube-system get ds kube-proxy
Une interface host-only (enp0s8, ou eth1 sur certaines box kubeadm) source de l'annonce ARP — jamais la carte NAT cat _out/cluster.env (kubeadm)
kubectl + helm, KUBECONFIG positionné le script vérifie les binaires, puis /readyz helm version

⚡ Installation#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour la formation.

En pratique tu ne le lances pas à la main : ../platform-up.sh l'appelle à l'étape [1/4] dès que CNI != cilium. L'appel direct est là pour une réparation ou pour la formation :

./install.sh <distro> metallb      # <distro> = talos | kubeadm
./metallb/metallb-up.sh <distro>

Chart metallb/metallb 0.16.1, lu depuis lab.env (METALLB_VERSION) et surchargeable au coup par coup (METALLB_VERSION=0.16.0 ./metallb/metallb-up.sh). Idempotent (helm upgrade --install + kubectl apply).

ℹ️ metallb est volontairement exclu de ./install.sh <distro> all : sur le CNI=cilium par défaut il refuse de s'installer, et un all inconditionnel s'arrêterait donc toujours là. C'est platform qui l'installe quand — et seulement quand — le CNI l'exige.

🧬 Talos vs kubeadm#

L'annonce elle-même est identique : même chart, même pool, même interface, même sélection de nodes. Une seule chose diverge vraiment, et c'est l'admission.

Point Talos kubeadm Conséquence
Défaut PodSecurity baseline imposé sur tout le cluster aucun niveau imposé Le speaker tourne en hostNetwork et ajoute NET_RAWbaseline interdit les deux. namespace.yaml est donc obligatoire sur Talos et documentaire sur kubeadm.
Interface host-only toujours enp0s8 eth1 ou enp0s8 selon la box → détectée dans _out/cluster.env HOSTONLY_IF couvre les deux ; le script la substitue dans metallb-l2.yml.
kube-proxy toujours installé par Talos optionnel, mais KUBE_PROXY_REPLACEMENT=true force CNI=cilium MetalLB trouve donc toujours un kube-proxy devant lui, sur les deux labs.
CNI probable à côté calico, ou flannel pré-installé au bootstrap calico, ou flannel installé par platform-up.sh Aucun effet sur MetalLB : il ne voit que des nodes Ready et des Services.

ℹ️ Aucune variable de profil n'a été ajoutée pour ce composant : rien ici ne lit lib/profiles/<distro>.sh au-delà de DEFAULT_HOSTONLY_IF, qui existait déjà. La divergence PodSecurity est portée par un manifeste de namespace appliqué dans les deux cas — la règle du dépôt : étiqueter le namespace partout, même là où ça ne débloque rien aujourd'hui.

🎓 Pas à pas guidé (formation)#

La même chose que le script, une commande à la fois. Pose d'abord tes variables :

export KUBECONFIG="$PWD/kubeconfig"           # depuis la racine du lab
LB_POOL_START=$(sed -n 's/^LB_POOL_START=//p' lab.env | head -1 | tr -d ' "')
LB_POOL_END=$(sed -n 's/^LB_POOL_END=//p'     lab.env | head -1 | tr -d ' "')
HOSTONLY_IF=$(sed -n 's/^HOSTONLY_IF=//p' _out/cluster.env | head -1)   # kubeadm
HOSTONLY_IF=${HOSTONLY_IF:-enp0s8}                                      # Talos
echo "$LB_POOL_START-$LB_POOL_END sur $HOSTONLY_IF"

1. Vérifier le point de départ#

kubectl get nodes                         # tous Ready — le CNI est déjà là
kubectl get ciliuml2announcementpolicies.cilium.io 2>/dev/null   # DOIT être vide / pas de CRD
kubectl -n envoy-gateway-system get svc   # EXTERNAL-IP <pending>, si la Gateway est debout

2. Le namespace, avant le chart#

kubectl apply -f metallb/namespace.yaml
kubectl get ns metallb-system --show-labels     # pod-security.kubernetes.io/enforce=privileged

3. Ajouter le dépôt Helm#

helm repo add metallb https://metallb.github.io/metallb
helm repo update metallb

4. Installer MetalLB — L2 uniquement, sans FRR#

helm upgrade --install metallb metallb/metallb \
  --namespace metallb-system \
  --version 0.16.1 \
  --set frrk8s.enabled=false \
  --set speaker.frr.enabled=false

5. Attendre le controller et les speakers#

kubectl -n metallb-system rollout status deploy/metallb-controller --timeout=300s
kubectl -n metallb-system rollout status daemonset/metallb-speaker --timeout=300s

6. Appliquer le pool + l'annonce L2#

# Les valeurs versionnées sont les défauts du lab : substitue les tiennes, comme le fait le script
sed -e "s/192\.168\.56\.200/${LB_POOL_START}/g" \
    -e "s/192\.168\.56\.230/${LB_POOL_END}/g" \
    -e "s/enp0s8/${HOSTONLY_IF}/g" \
    metallb/metallb-l2.yml | kubectl apply -f -

⚠️ Si ça échoue en connection refused ou sur une erreur x509 visant metallb-webhook-service, tu es simplement en avance : le controller injecte son propre certificat dans la ValidatingWebhookConfiguration, ce qui prend quelques secondes après le rollout. Réessaie. C'est exactement ce que fait la boucle du script.

7. Vérifier#

kubectl -n metallb-system get ipaddresspool,l2advertisement
kubectl -n envoy-gateway-system get svc          # EXTERNAL-IP = 192.168.56.200
kubectl get servicel2status -A -o wide           # quel node annonce quelle IP

🔧 Ce que fait le script#

Étape Quoi Pourquoi ce n'est pas juste un helm install
garde-fous refuse si Cilium annonce, ou si CNI=cilium ; refuse si aucun node n'est Ready deux annonceurs ARP sur une plage, c'est la panne la plus illisible de ce lab ; et MetalLB sur un cluster sans CNI reste Pending 5 min avant de dire quoi que ce soit
[1/3] namespace.yaml puis le chart helm --create-namespace ne pose aucun label PodSecurity — et leur absence échoue en silence (voir plus bas)
[2/3] rollout status sur le controller et le DaemonSet le controller attribue, les speakers annoncent : une IP sans speaker est attribuée et injoignable
[3/3] metallb-l2.yml rendu, appliqué avec réessai le webhook de validation est servi par le controller et met quelques secondes à présenter un certificat de confiance

Fichiers#

Fichier Rôle
metallb-up.sh l'installation tout-en-un, idempotente
namespace.yaml metallb-system + les trois labels PodSecurity privileged
metallb-l2.yml IPAddressPool + L2Advertisement — le miroir strict de ../cilium/cilium-l2.yml

Les réglages Helm qui comptent#

--set Valeur Pourquoi
frrk8s.enabled false Depuis le chart 0.15 le sous-chart FRR-K8s est activé par défaut : un démon de routage FRR complet sur chaque node, pour BGP. Ce lab annonce en L2 uniquement (pas de routeur pair sur un réseau host-only VirtualBox) — du poids mort.
speaker.frr.enabled false L'autre manière, dépréciée, de faire entrer FRR dans le pod speaker. Le chart refuse les deux à la fois : on les coupe donc explicitement plutôt que de faire confiance à celui qui vaut false par défaut dans la version du jour.

Tout le reste reste au défaut du chart, volontairement — en particulier speaker.tolerateMaster=true, qui fait tourner un speaker sur les control planes aussi. C'est sans conséquence : ce qui décide qui annonce, c'est le nodeSelectors de la L2Advertisement, exactement comme la policy Cilium.

metallb-l2.yml — deux objets#

  • IPAddressPool — la plage, écrite en une seule chaîne start-stop (Cilium utilise deux champs). autoAssign: true : la Gateway ne demande aucune adresse en particulier et reçoit la première libre.
  • L2Advertisement — qui annonce, et sur quelle carte. ipAddressPools nomme le pool (sans lui l'annonce couvrirait tous les pools), interfaces épingle la carte host-only et nodeSelectors écarte les control planes.

✅ Vérifier#

kubectl -n metallb-system get pods                      # 1 controller + 1 speaker par node
kubectl -n metallb-system get ipaddresspool -o wide     # la plage
kubectl -n metallb-system get l2advertisement -o yaml | grep -A4 interfaces

# La Gateway doit avoir pris la PREMIÈRE IP du pool
kubectl -n envoy-gateway-system get svc

# Qui annonce quoi (l'équivalent MetalLB des leases Cilium)
kubectl get servicel2status -A -o wide

Et surtout, l'IP doit répondre — ce qu'aucun kubectl get ne prouve :

GWIP=$(kubectl -n envoy-gateway-system get svc \
  -o jsonpath='{.items[?(@.spec.type=="LoadBalancer")].status.loadBalancer.ingress[0].ip}')
curl -s -o /dev/null -w '%{http_code}\n' "http://$GWIP/"   # 404 = Envoy répond (pas encore de route)
ip neigh show "$GWIP"                                      # ARP résolu = annonce L2 OK

⚠️ Ne teste JAMAIS cette IP au ping. Aucune interface ne porte réellement l'adresse : le speaker élu se contente de répondre à l'ARP pour attirer le trafic, puis le node l'achemine. L'ICMP vers le VIP ne reçoit donc rien, alors qu'un ping sur un node (.101) fonctionne — un faux négatif très convaincant. La preuve de l'annonce, c'est l'entrée ARP qui se résout vers la MAC d'un worker :

ip neigh flush "$GWIP"; curl -s -o /dev/null --max-time 5 "http://$GWIP/"
ip neigh show "$GWIP"     # lladdr = MAC du worker élu

🧪 Scénario — bascule de speaker#

La démo qui justifie le composant : l'annonce survit à la perte d'un node.

kubectl get servicel2status -A -o wide          # note le node annonceur, p. ex. k8s-w2
ip neigh show "$GWIP"                           # note la MAC

# Retire le speaker de ce node
kubectl -n metallb-system delete pod -l app.kubernetes.io/component=speaker \
  --field-selector spec.nodeName=k8s-w2

# Quelques secondes plus tard, un autre worker a repris la main
kubectl get servicel2status -A -o wide
ip neigh flush "$GWIP"; curl -s -o /dev/null -w '%{http_code}\n' "http://$GWIP/"
ip neigh show "$GWIP"                           # une AUTRE MAC — la même IP

Les quelques secondes de coupure, c'est le ré-ARP : attendu, pas un incident.

🚑 Dépannage#

Symptôme Cause probable Quoi faire
Le script échoue sur « Cilium already announces » tu es en CNI=cilium — le défaut du lab c'est le garde-fou, pas un bug : Cilium le fait déjà. Voir ⚠️ Pièges
Le script échoue sur « no Ready node » aucun CNI installé installe d'abord le CNI — MetalLB n'en est pas un
EXTERNAL-IP reste <pending> avec un pool valide le Service porte encore loadBalancerClass: io.cilium/l2-announcer platform-up.sh retire cette ligne quand CNI != cilium ; vérifie kubectl -n envoy-gateway-system get svc -o yaml | grep -i loadbalancerclass
EXTERNAL-IP posée mais rien ne répond zéro pod speaker → PodSecurity a refusé le DaemonSet kubectl -n metallb-system describe ds/metallb-speaker, puis applique namespace.yaml et rollout restart
L'application du pool échoue en connection refused / x509 le webhook du controller ne sert pas encore attends 10 s et réessaie — le script boucle 150 s
L'ARP se résout vers un control plane les nodeSelectors ont sauté ou le label diffère kubectl -n metallb-system get l2advertisement -o yaml
L'ARP ne se résout pas du tout annonce sur la carte NAT, ou interfaces écrit comme une regex kubectl -n metallb-system get l2advertisement -o yaml — il doit y avoir enp0s8, pas ^enp0s8$
no available IPs dans les logs du controller pool épuisé (31 adresses) ou deux Services demandant une IP fixe kubectl -n metallb-system logs deploy/metallb-controller

⚠️ Pièges#

  • Jamais MetalLB et l'annonce L2 de Cilium. Deux speakers répondant à l'ARP pour 192.168.56.200 font osciller le cache ARP de l'hôte entre deux MAC : le point d'entrée marche « une fois sur deux », et aucun log ne le dit. Le script refuse sur deux signaux indépendants (les objets cilium.io vivants, et CNI qui résout vers cilium) — ne le contourne pas, la bonne réaction est de choisir un CNI.
  • interfaces: est une liste de noms bruts, pas de regex. La policy Cilium prend ^enp0s8$ ; MetalLB prend enp0s8. Recopier la syntaxe Cilium ne correspond à aucune interface et l'annonce est abandonnée en silence : pool valide, IP attribuée, ARP muet.
  • loadBalancerClass l'emporte sur n'importe quel pool. Tant que ../envoy-gateway/Envoy-Proxy.yml épingle loadBalancerClass: io.cilium/l2-announcer, MetalLB ignore le Service — une loadBalancerClass signifie « seul ce contrôleur a le droit de le servir ». platform-up.sh retire la ligne quand CNI != cilium ; appliquer ce manifeste à la main, non.
  • Les labels privileged ne sont pas cosmétiques, et leur absence te ment. Le controller n'est pas privilégié : il démarre, attribue, et écrit une EXTERNAL-IP parfaitement normale dans le Service. Seul le speaker est refusé — tu obtiens donc une adresse qui a l'air posée et qui ne répond à rien. Recoupe toujours get svc avec kubectl -n metallb-system get pods.
  • WORKERS=0 ne laisse personne pour annoncer. Les nodeSelectors écartent les control planes, volontairement. Sur un lab sans worker, retire-les de metallb-l2.yml — sinon le pool est valide et personne ne l'annonce.
  • Un node par IP, ce n'est pas de la répartition de charge. Comme CiliumL2AnnouncementPolicy, le mode L2 élit un seul speaker par adresse : tout le trafic du VIP entre par ce node, puis kube-proxy le répartit sur les endpoints. Une bascule prend quelques secondes (le temps du ré-ARP).
  • kube-proxy en mode IPVS exige strictARP: true. Aucun des deux labs n'utilise IPVS (tous deux sont en mode iptables), donc ça ne mord pas ici — mais c'est la première chose à vérifier si tu emportes ce composant sur un autre cluster : en IPVS sans strictARP, tous les nodes répondent à l'ARP du VIP.
  • Changer de CNI n'est pas une bascule à chaud. Passer de Cilium à Calico impose de reconstruire le cluster (./kubeadm/cluster-reset.sh, ou vagrant destroy), pas un helm uninstall. MetalLB n'a de sens que sur un cluster bootstrapé avec le bon CNI dès le départ.

🧹 Désinstaller#

kubectl -n metallb-system delete -f metallb/metallb-l2.yml    # arrêter d'annoncer d'abord
helm uninstall metallb -n metallb-system
kubectl delete ns metallb-system

⚠️ Tous les Services LoadBalancer retombent en EXTERNAL-IP <pending> et toutes les UI deviennent injoignables — la Gateway comprise. Les CRD partent avec le chart, donc tout IPAddressPool résiduel disparaît avec elles.

📚 Références#

🚪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 *.lab.example.io), and every component plugs in with an HTTPRoute.

🌐 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-lab-example-io-tls Secret of the :443 listener kubectl -n envoy-gateway-system get secret wildcard-…-tls
Name resolution for *.lab.example.io → 192.168.56.200 routes match by hostname dig +short argo.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#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> platform     # <distro> = talos | kubeadm

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

./platform-up.sh <distro>

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 envoy-gateway/Envoy-Proxy.yml

🧬 Talos vs kubeadm#

No distribution-specific behaviour for this component: same charts, same manifests, same values on both labs. The distribution argument only drives two things here: the default domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env / kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).

ℹ️ One detail depends on the CNI (not on the distribution): loadBalancerClass: io.cilium/l2-announcer in Envoy-Proxy.yml. platform-up.sh STRIPS it when CNI != cilium, otherwise the announcer that serves this Service in that case — ../metallb/ — would ignore it and the IP would stay <pending> with a perfectly valid pool next to it.

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. Install the controller (OCI chart — no helm repo add)#

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 --timeout=180s
kubectl get crd | grep gateway.networking.k8s.io      # Gateway API CRDs ship with the chart

2. Apply the GatewayClass + main-gateway (listeners :80 and :443)#

The manifest carries the NEUTRAL domain lab.example.io and the wildcard-lab-example-io-tls Secret name: both get substituted.

DASH="${LAB_DOMAIN//./-}"
sed -e "s/lab\.example\.io/${LAB_DOMAIN}/g" -e "s/lab-example-io/${DASH}/g" \
    envoy-gateway/Envoy-Proxy.yml \
  | sed '/loadBalancerClass:/d' \        # ← ONLY when the CNI is not Cilium
  | kubectl apply -f -

3. Wait for the LoadBalancer IP (Cilium L2 announcement)#

kubectl -n envoy-gateway-system get svc -w      # expected EXTERNAL-IP: 192.168.56.200
kubectl -n envoy-gateway-system get gateway main-gateway -o wide

4. Verify the listeners and the certificate#

kubectl -n envoy-gateway-system get gateway main-gateway \
  -o jsonpath='{range .status.listeners[*]}{.name}{"\t"}{.conditions[?(@.type=="Programmed")].status}{"\n"}{end}'
kubectl -n envoy-gateway-system get secret "wildcard-${LAB_DOMAIN//./-}-tls"

5. (Optional) Deploy the two demo apps#

kubectl apply -f envoy-gateway/GW-Example.yml
curl --resolve "hello.${LAB_DOMAIN}:443:192.168.56.200" "https://hello.${LAB_DOMAIN}/" -k

🔧 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 *.lab.example.io Terminate, certificateRefs: wildcard-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.lab.example.io      # must match the wildcard *.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.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 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 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.lab.example.io/hello200). On the other hand https://hello.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.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 would also trip the restricted PodSecurity profile (allowPrivilegeEscalation, capabilities, runAsNonRoot, seccompProfile) — but on kubeadm the PodSecurity admission enforces nothing cluster-wide by default, so nothing complains until you label the namespace or install Kyverno. 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#

🚪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 *.lab.example.io), et chaque addon s'y branche avec une HTTPRoute.

🌐 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-lab-example-io-tls de l'écouteur :443 kubectl -n envoy-gateway-system get secret wildcard-…-tls
Résolution de *.lab.example.io → 192.168.56.200 les routes matchent par hostname dig +short argo.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#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> platform     # <distro> = talos | kubeadm

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

./platform-up.sh <distro>

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 envoy-gateway/Envoy-Proxy.yml

🧬 Talos vs kubeadm#

Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes, mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou ../Vagrant-KubeADM/).

ℹ️ Un seul détail dépend du CNI (pas de la distribution) : loadBalancerClass: io.cilium/l2-announcer dans Envoy-Proxy.yml. platform-up.sh la RETIRE quand CNI != cilium, sinon l'annonceur qui sert ce Service dans ce cas — ../metallb/ — l'ignorerait et l'IP resterait <pending> avec un pool parfaitement valide à côté.

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Installer le contrôleur (chart OCI, pas de helm repo add)#

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 --timeout=180s
kubectl get crd | grep gateway.networking.k8s.io      # les CRD Gateway API arrivent avec le chart

2. Poser la GatewayClass + main-gateway (écouteurs :80 et :443)#

Le manifeste porte le domaine NEUTRE lab.example.io et le Secret wildcard-lab-example-io-tls : les deux sont substitués.

DASH="${LAB_DOMAIN//./-}"
sed -e "s/lab\.example\.io/${LAB_DOMAIN}/g" -e "s/lab-example-io/${DASH}/g" \
    envoy-gateway/Envoy-Proxy.yml \
  | sed '/loadBalancerClass:/d' \        # ← à faire UNIQUEMENT si le CNI n'est pas Cilium
  | kubectl apply -f -

3. Attendre l'IP LoadBalancer (annonce L2 de Cilium)#

kubectl -n envoy-gateway-system get svc -w      # EXTERNAL-IP attendue : 192.168.56.200
kubectl -n envoy-gateway-system get gateway main-gateway -o wide

4. Vérifier les écouteurs et le certificat#

kubectl -n envoy-gateway-system get gateway main-gateway \
  -o jsonpath='{range .status.listeners[*]}{.name}{"\t"}{.conditions[?(@.type=="Programmed")].status}{"\n"}{end}'
kubectl -n envoy-gateway-system get secret "wildcard-${LAB_DOMAIN//./-}-tls"

5. (Optionnel) Déployer les deux apps de démo#

kubectl apply -f envoy-gateway/GW-Example.yml
curl --resolve "hello.${LAB_DOMAIN}:443:192.168.56.200" "https://hello.${LAB_DOMAIN}/" -k

🔧 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 *.lab.example.io Terminate, certificateRefs: wildcard-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.lab.example.io     # doit matcher le wildcard *.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.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 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 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.lab.example.io/hello200). En revanche https://hello.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.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 violeraient aussi le profil PodSecurity restricted (allowPrivilegeEscalation, capabilities, runAsNonRoot, seccompProfile) — mais sur kubeadm l'admission PodSecurity n'applique rien au niveau cluster par défaut : personne ne proteste tant qu'on n'a pas étiqueté le namespace ou installé Kyverno. 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#

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

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> self-signed     # <distro> = talos | kubeadm

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

./platform-up.sh <distro>

Standalone, on an existing platform:

./self-signed/selfsigned-up.sh <distro>

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

🧬 Talos vs kubeadm#

A single difference — cosmetic, but useful when both labs run side by side: the CA subject and the suggested file name for the trust store import.

Talos kubeadm
CA subject (CA_ORG) O=Vagrant-Talos lab O=Vagrant-KubeADM lab
Suggested file (CA_FILE_NAME) vagrant-talos-lab.crt vagrant-kubeadm-lab.crt
Default wildcard domain *.talos.lab.example.io *.kubeadm.lab.example.io

The CA and its key live in the lab's _out/self-signed/ (gitignored): they survive a vagrant destroy, so the browser security exception does not have to be re-accepted on every rebuild.

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. Prepare the output directory (inside the lab repository)#

LAB=../Vagrant-Talos              # or ../Vagrant-KubeADM
mkdir -p "$LAB/_out/self-signed" && chmod 700 "$LAB/_out/self-signed"
cd "$LAB/_out/self-signed"

2. The local CA — generated ONCE, reused afterwards (10 years)#

openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
  -keyout ca.key -out ca.crt \
  -subj "/O=Vagrant-Talos lab/CN=Vagrant-Talos lab self-signed CA" \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign"
openssl x509 -in ca.crt -noout -subject -dates

3. The *.<LAB_DOMAIN> leaf certificate (825 days — the browser limit)#

openssl req -new -newkey rsa:2048 -nodes -keyout tls.key -out tls.csr \
  -subj "/O=Vagrant-Talos lab/CN=*.${LAB_DOMAIN}"
printf 'subjectAltName=DNS:*.%s,DNS:%s\nextendedKeyUsage=serverAuth\n' "$LAB_DOMAIN" "$LAB_DOMAIN" > ext.cnf
openssl x509 -req -in tls.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -days 825 -sha256 -extfile ext.cnf -out tls.crt
openssl x509 -in tls.crt -noout -text | grep -A1 'Subject Alternative Name'

4. The TLS Secret main-gateway expects#

The name must be wildcard-<domain-with-dashes>-tls: that is what the https listener references. cert-manager would fill the very same Secret — the Gateway never knows which mode you picked.

cd -   # back into k8s-playground
kubectl create namespace envoy-gateway-system --dry-run=client -o yaml | kubectl apply -f -
kubectl -n envoy-gateway-system create secret tls "wildcard-${LAB_DOMAIN//./-}-tls" \
  --cert="$LAB/_out/self-signed/tls.crt" --key="$LAB/_out/self-signed/tls.key" \
  --dry-run=client -o yaml | kubectl apply -f -

5. Verify end to end#

kubectl -n envoy-gateway-system get secret "wildcard-${LAB_DOMAIN//./-}-tls"
curl --resolve "argo.${LAB_DOMAIN}:443:192.168.56.200" "https://argo.${LAB_DOMAIN}/" \
     --cacert "$LAB/_out/self-signed/ca.crt" -sSI | head -1

6. Silence the browser warning (once)#

# Linux (Debian/Ubuntu)
sudo cp "$LAB/_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 \
  "$LAB/_out/self-signed/ca.crt"

🔧 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-KubeADM 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-kubeadm-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#

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

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> self-signed     # <distro> = talos | kubeadm

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

./platform-up.sh <distro>

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

./self-signed/selfsigned-up.sh <distro>

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

🧬 Talos vs kubeadm#

Une seule différence, cosmétique mais utile quand les deux labs tournent côte à côte : le sujet de l'AC et le nom de fichier suggéré à l'import dans le trousseau.

Talos kubeadm
Sujet de l'AC (CA_ORG) O=Vagrant-Talos lab O=Vagrant-KubeADM lab
Fichier suggéré (CA_FILE_NAME) vagrant-talos-lab.crt vagrant-kubeadm-lab.crt
Domaine par défaut du wildcard *.talos.lab.example.io *.kubeadm.lab.example.io

L'AC et la clé vivent dans le _out/self-signed/ du lab (gitignoré) : elles survivent à un vagrant destroy, donc l'exception de sécurité du navigateur ne se rejoue pas à chaque rebuild.

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Préparer le dossier de sortie (dans le dépôt du lab)#

LAB=../Vagrant-Talos              # ou ../Vagrant-KubeADM
mkdir -p "$LAB/_out/self-signed" && chmod 700 "$LAB/_out/self-signed"
cd "$LAB/_out/self-signed"

2. L'AC locale — générée UNE FOIS, réutilisée ensuite (10 ans)#

openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
  -keyout ca.key -out ca.crt \
  -subj "/O=Vagrant-Talos lab/CN=Vagrant-Talos lab self-signed CA" \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign"
openssl x509 -in ca.crt -noout -subject -dates

3. Le certificat feuille *.<LAB_DOMAIN> (825 jours — la limite des navigateurs)#

openssl req -new -newkey rsa:2048 -nodes -keyout tls.key -out tls.csr \
  -subj "/O=Vagrant-Talos lab/CN=*.${LAB_DOMAIN}"
printf 'subjectAltName=DNS:*.%s,DNS:%s\nextendedKeyUsage=serverAuth\n' "$LAB_DOMAIN" "$LAB_DOMAIN" > ext.cnf
openssl x509 -req -in tls.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -days 825 -sha256 -extfile ext.cnf -out tls.crt
openssl x509 -in tls.crt -noout -text | grep -A1 'Subject Alternative Name'

4. Le Secret TLS attendu par main-gateway#

Le nom doit être wildcard-<domaine-en-tirets>-tls : c'est celui que référence l'écouteur https. cert-manager remplirait le même Secret — la Gateway n'a rien à savoir du mode choisi.

cd -   # retour dans k8s-playground
kubectl create namespace envoy-gateway-system --dry-run=client -o yaml | kubectl apply -f -
kubectl -n envoy-gateway-system create secret tls "wildcard-${LAB_DOMAIN//./-}-tls" \
  --cert="$LAB/_out/self-signed/tls.crt" --key="$LAB/_out/self-signed/tls.key" \
  --dry-run=client -o yaml | kubectl apply -f -

5. Vérifier de bout en bout#

kubectl -n envoy-gateway-system get secret "wildcard-${LAB_DOMAIN//./-}-tls"
curl --resolve "argo.${LAB_DOMAIN}:443:192.168.56.200" "https://argo.${LAB_DOMAIN}/" \
     --cacert "$LAB/_out/self-signed/ca.crt" -sSI | head -1

6. Faire taire l'avertissement du navigateur (une fois)#

# Linux (Debian/Ubuntu)
sudo cp "$LAB/_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 \
  "$LAB/_out/self-signed/ca.crt"

🔧 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-KubeADM 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-kubeadm-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#

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

A public *.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.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 *.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, *.lab.example.io → 192.168.56.200 in DNS-only mode the DNS-01 solver writes into that zone dig +short TXT _acme-challenge.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 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#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> platform     # <distro> = talos | kubeadm

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
./platform-up.sh <distro>

Chart jetstack/cert-manager v1.21.1, 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-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.21.1 \
  --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 cert-manager/02-clusterissuer-staging.yaml \
              -f cert-manager/03-clusterissuer-prod.yaml

🧬 Talos vs kubeadm#

No distribution-specific behaviour for this component: same charts, same manifests, same values on both labs. The distribution argument only drives two things here: the default domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env / kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).

ℹ️ This path only kicks in when SELF_SIGNED=false in the lab's lab.env. The default mode is the local CA (../self-signed/) — and both fill the same wildcard-<domain-with-dashes>-tls Secret.

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. The chart (with CRDs and Gateway API support)#

helm repo add jetstack https://charts.jetstack.io && helm repo update jetstack
helm upgrade --install cert-manager jetstack/cert-manager \
  --namespace cert-manager --create-namespace \
  --version v1.21.1 \
  --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 --timeout=180s

config.enableGatewayAPI=true is the key setting: without it the cert-manager.io/cluster-issuer annotation on main-gateway is ignored and no Certificate is ever created.

2. The Cloudflare token Secret (DNS-01 solver)#

kubectl create secret generic cloudflare-api-token -n cert-manager \
  --from-literal=api-token="$CLOUDFLARE_API_TOKEN" \
  --dry-run=client -o yaml | kubectl apply -f -

3. The two ClusterIssuers (staging first, then prod)#

Staging allows ~30,000 certs/week, production only 5 for the same wildcard: always start with staging.

ZONE=$(printf '%s\n' "$LAB_DOMAIN" | awk -F. '{print $(NF-1)"."$NF}')
for i in 02-clusterissuer-staging 03-clusterissuer-prod; do
  sed -e "s/lab\.example\.io/${LAB_DOMAIN}/g" \
      -e "s/admin@example\.io/admin@${ZONE}/g" \
      -e "s/^\([[:space:]]*-[[:space:]]\)example\.io/\1${ZONE}/" \
      "cert-manager/${i}.yaml" | kubectl apply -f -
done
kubectl get clusterissuer

4. Trigger the wildcard issuance#

The annotation on main-gateway is enough: cert-manager creates the Certificate and fills the Secret.

kubectl -n envoy-gateway-system annotate gateway main-gateway \
  cert-manager.io/cluster-issuer=letsencrypt-staging --overwrite
kubectl -n envoy-gateway-system get certificate -w        # Ready=True within 1-2 min

5. Follow the DNS-01 challenge when it stalls#

kubectl -n envoy-gateway-system describe certificate "wildcard-${LAB_DOMAIN//./-}-tls" | tail -20
kubectl get challenges -A
kubectl -n cert-manager logs deploy/cert-manager --tail=50

6. Move to production (browser-trusted certificate)#

kubectl -n envoy-gateway-system annotate gateway main-gateway \
  cert-manager.io/cluster-issuer=letsencrypt-prod --overwrite
kubectl -n envoy-gateway-system delete secret "wildcard-${LAB_DOMAIN//./-}-tls"   # forces re-issuance

🔧 How the certificate is issued#

Gateway main-gateway
  ├─ annotation cert-manager.io/cluster-issuer: letsencrypt-staging   (LAB_ACME_ISSUER)
  └─ listener https (hostname *.lab.example.io, certificateRefs: wildcard-lab-example-io-tls)
        │
        ▼  cert-manager (config.enableGatewayAPI=true) watches the Gateway
   Certificate wildcard-lab-example-io-tls   (dnsNames derived from the listener `hostname`)
        │  Order ──► Challenge dns-01 ──► TXT _acme-challenge.lab.example.io (Cloudflare API)
        ▼
   Secret wildcard-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: ["*.lab.example.io"], issuerRef: letsencrypt-staging, secretName: wildcard-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-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.lab.example.io 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
# expected: subject=CN=*.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.lab.example.io:443:192.168.56.200 https://argo.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).

⚠️ 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: *.lab.example.io covers argo.lab.example.io, not a.b.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#

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

Un certificat *.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.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 *.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, *.lab.example.io → 192.168.56.200 en DNS-only le solveur DNS-01 écrit dans cette zone dig +short TXT _acme-challenge.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 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#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> platform     # <distro> = talos | kubeadm

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é
./platform-up.sh <distro>

Chart jetstack/cert-manager v1.21.1, é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-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.21.1 \
  --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 cert-manager/02-clusterissuer-staging.yaml \
              -f cert-manager/03-clusterissuer-prod.yaml

🧬 Talos vs kubeadm#

Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes, mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou ../Vagrant-KubeADM/).

ℹ️ Ce chemin ne s'active que si SELF_SIGNED=false dans le lab.env du lab. Le mode par défaut est l'AC locale (../self-signed/) — et les deux remplissent le même Secret wildcard-<domaine-en-tirets>-tls.

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Le chart (avec les CRD et le support Gateway API)#

helm repo add jetstack https://charts.jetstack.io && helm repo update jetstack
helm upgrade --install cert-manager jetstack/cert-manager \
  --namespace cert-manager --create-namespace \
  --version v1.21.1 \
  --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 --timeout=180s

config.enableGatewayAPI=true est la clé : sans elle, l'annotation cert-manager.io/cluster-issuer posée sur main-gateway est ignorée et aucun Certificate n'est créé.

2. Le Secret du token Cloudflare (solveur DNS-01)#

kubectl create secret generic cloudflare-api-token -n cert-manager \
  --from-literal=api-token="$CLOUDFLARE_API_TOKEN" \
  --dry-run=client -o yaml | kubectl apply -f -

3. Les deux ClusterIssuers (staging puis prod)#

Le staging a un quota ~30 000 certs/semaine, la prod 5 pour un même wildcard : on commence toujours par le staging.

ZONE=$(printf '%s\n' "$LAB_DOMAIN" | awk -F. '{print $(NF-1)"."$NF}')
for i in 02-clusterissuer-staging 03-clusterissuer-prod; do
  sed -e "s/lab\.example\.io/${LAB_DOMAIN}/g" \
      -e "s/admin@example\.io/admin@${ZONE}/g" \
      -e "s/^\([[:space:]]*-[[:space:]]\)example\.io/\1${ZONE}/" \
      "cert-manager/${i}.yaml" | kubectl apply -f -
done
kubectl get clusterissuer

4. Déclencher l'émission du wildcard#

L'annotation sur main-gateway suffit : cert-manager crée le Certificate et remplit le Secret.

kubectl -n envoy-gateway-system annotate gateway main-gateway \
  cert-manager.io/cluster-issuer=letsencrypt-staging --overwrite
kubectl -n envoy-gateway-system get certificate -w        # Ready=True en 1-2 min

5. Suivre le challenge DNS-01 quand ça coince#

kubectl -n envoy-gateway-system describe certificate "wildcard-${LAB_DOMAIN//./-}-tls" | tail -20
kubectl get challenges -A
kubectl -n cert-manager logs deploy/cert-manager --tail=50

6. Passer en production (cert trusté par le navigateur)#

kubectl -n envoy-gateway-system annotate gateway main-gateway \
  cert-manager.io/cluster-issuer=letsencrypt-prod --overwrite
kubectl -n envoy-gateway-system delete secret "wildcard-${LAB_DOMAIN//./-}-tls"   # force la ré-émission

🔧 Comment le certificat est émis#

Gateway main-gateway
  ├─ annotation cert-manager.io/cluster-issuer: letsencrypt-staging   (LAB_ACME_ISSUER)
  └─ listener https (hostname *.lab.example.io, certificateRefs: wildcard-lab-example-io-tls)
        │
        ▼  cert-manager (config.enableGatewayAPI=true) observe le Gateway
   Certificate wildcard-lab-example-io-tls   (dnsNames déduits du `hostname` de l'écouteur)
        │  Order ──► Challenge dns-01 ──► TXT _acme-challenge.lab.example.io (API Cloudflare)
        ▼
   Secret wildcard-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: ["*.lab.example.io"], issuerRef: letsencrypt-staging, secretName: wildcard-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-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.lab.example.io 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
# attendu : subject=CN=*.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.lab.example.io:443:192.168.56.200 https://argo.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).

⚠️ 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 : *.lab.example.io couvre argo.lab.example.io, pas a.b.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#

🐮longhorn/ — replicated block storage (Longhorn 1.12) on Talos and kubeadm

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 one per worker, capped at 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: namespace, chart + both StorageClasses + HTTPRoute
values.yaml Helm values: defaultDataPath, defaultReplicaCount, persistence.defaultClass: true
longhorn-r1-storageclass.yaml Baseline StorageClass longhorn-r1 (1 block replica)
httproute.yaml HTTPS HTTPRoute longhorn.lab.example.iolonghorn-frontend:80 on main-gateway

📋 Prerequisites#

Prerequisite Why Verify
iSCSI on every nodekubeadm: open-iscsi + nfs-common packages, iscsid running, iscsi_tcp module, installed by kubeadm/provision.sh. Talos: iscsi-tools + util-linux-tools extensions BAKED into the installer image (see schematic.yaml), checked by longhorn-up.sh longhorn-manager and the CSI plugin shell out to iscsiadm to attach volumes. Without it, the CSI pods CrashLoopBackOff with iscsiadm: not found kubeadm: vagrant ssh k8s-w1 -c 'systemctl is-active iscsid' · Talos: talosctl -n <ip> get extensions
rshared kubelet mount on /var/lib/longhornTalos only (patch-longhorn.yaml, applied by longhorn-up.sh) the Talos kubelet runs in a container, without bidirectional mount propagation talosctl -n <ip> get mc -o yaml | grep /var/lib/longhorn
talosctl in PATHTalos only extension check + mount patch talosctl version --client
helm in PATH the chart helm version
Namespace longhorn-system with PodSecurity privileged Longhorn pods are privileged (iSCSI, hostPath) — applied by longhorn-up.sh 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

ℹ️ Both heavy prerequisites exist on Talos only — which is what makes this the most distribution-dependent add-on in the repository:

Talos kubeadm / Debian
iscsi-tools + util-linux-tools system extensions, baked into the installer image. A node without them cannot be fixed live — it must be re-installed or upgraded to a new Image Factory ref. longhorn-up.sh can only check (talosctl get extensions) and refuse to go further. Two apt packages. kubeadm/provision.sh runs apt-get install -y open-iscsi nfs-common, systemctl enable --now iscsid and loads iscsi_tcp (/etc/modules-load.d/iscsi.conf) on every node, at provisioning time. Nothing to check, nothing to bake.
rshared kubelet mount on /var/lib/longhorn, applied through talosctl patch mc (hot, no reboot): the Talos kubelet runs in a container and lacks bidirectional mount propagation. /var/lib/longhorn is an ordinary directory on the root filesystem and the kubelet runs directly on the host: mount propagation is already correct. Nothing to patch.

Consequence: on Talos, longhorn-up.sh requires talosctl and runs 5 steps; on kubeadm it runs 3. patch-longhorn.yaml and schematic.yaml are Talos-only files.

⚡ Install#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> longhorn     # <distro> = talos | kubeadm

Pinned version: chart Longhorn 1.12.0.

./longhorn/longhorn-up.sh <distro>

Idempotent: re-runnable without breaking anything (helm upgrade --install). It covers both steps below. It counts the schedulable worker nodes on the live cluster (rather than trusting WORKERS in lab.env, which only states an intent) and aligns the block replica count with it, capped at 3. REPLICAS=… forces the count, LONGHORN_VERSION=… overrides the chart version.

ℹ️ With WORKERS=0 the control planes are untainted (UNTAINT_CP=auto) and become the only storage nodes: the script counts them instead of bailing out.

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

ℹ️ A kubeadm cluster enforces no PodSecurity level by default, so these labels change nothing today. They are kept because they document the intent and keep the namespace working if the cluster is hardened later (--admission-control-config-file on the apiserver).

2. 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 longhorn/values.yaml
kubectl -n longhorn-system rollout status deploy/longhorn-driver-deployer
kubectl apply -f longhorn/longhorn-r1-storageclass.yaml

🧬 Talos vs kubeadm#

The iSCSI prerequisite does not live in the same place on both distributions — that is THE structural difference of this component (LONGHORN_PREP_REQUIRED in the profiles).

Talos kubeadm
iSCSI (iscsiadm) a system extension, iscsi-tools + util-linux-tools, BAKED into the installer image (longhorn/schematic.yaml → factory image, INSTALLER_IMAGE in lab.env). A node without them cannot be fixed at runtime: CSI pods go CrashLoopBackOff (iscsiadm: not found) a package: apt-get install -y open-iscsi nfs-common + systemctl enable --now iscsid + the iscsi_tcp module, installed by kubeadm/provision.sh at provisioning time
Mount propagation an rshared kubelet mount on /var/lib/longhorn must be applied (longhorn/patch-longhorn.yaml, talosctl patch mc, hot, no reboot) — the Talos kubelet runs in a container nothing to do: the kubelet runs on the host and /var/lib/longhorn is an ordinary directory
Host tooling required kubectl, helm, talosctl kubectl, helm
Script steps 5 (extensions → patch → namespace → chart → HTTPRoute) 3 (namespace → chart → HTTPRoute)
Where the data lives the EPHEMERAL partition (lost on a reset without --preserve), ~20 GB disk shared with the OS the box disk, shared with the OS and container images
privileged PodSecurity label required (cluster default baseline) intent documentation

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. (Talos only) Check the iSCSI extensions — before anything else#

An extension is baked into the installer: if it is missing, no kubectl will save you, the node must be reinstalled or upgraded.

export TALOSCONFIG=../Vagrant-Talos/_out/talosconfig
for ip in 192.168.56.101 192.168.56.102 192.168.56.103; do
  echo "== $ip"; talosctl -n "$ip" get extensions | grep -E 'iscsi-tools|util-linux-tools'
done
# Missing? Generate the factory image from longhorn/schematic.yaml, then:
#   talosctl -n <ip> upgrade --image <factory-image> --preserve

2. (Talos only) Apply the rshared kubelet mount#

talosctl -n 192.168.56.101 get mc -o yaml | grep -q /var/lib/longhorn \
  || talosctl -n 192.168.56.101 patch mc --patch @longhorn/patch-longhorn.yaml
# … repeat on every worker. Applied hot, no reboot.

On kubeadm, steps 1 and 2 do not exist: open-iscsi is already installed and running on every node. Optional check: vagrant ssh k8s-w1 -c 'systemctl is-active iscsid; lsmod | grep iscsi_tcp'

3. Namespace + privileged PodSecurity#

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. Count the storage nodes (and never exceed them)#

A defaultReplicaCount higher than the number of schedulable nodes leaves every volume Degraded forever.

REPLICAS=$(kubectl get nodes -l '!node-role.kubernetes.io/control-plane' --no-headers | wc -l | tr -d ' ')
[ "$REPLICAS" -eq 0 ] && REPLICAS=$(kubectl get nodes --no-headers | wc -l | tr -d ' ')
[ "$REPLICAS" -gt 3 ] && REPLICAS=3
echo "block replicas: $REPLICAS"

5. The chart + the 1-replica StorageClass#

helm repo add longhorn https://charts.longhorn.io && helm repo update longhorn
helm upgrade --install longhorn longhorn/longhorn \
  --namespace longhorn-system \
  --version 1.12.0 \
  --values longhorn/values.yaml \
  --set "defaultSettings.defaultReplicaCount=${REPLICAS}" \
  --set "persistence.defaultClassReplicaCount=${REPLICAS}" \
  --wait --timeout 10m
kubectl -n longhorn-system rollout status deploy/longhorn-driver-deployer --timeout=300s
kubectl apply -f longhorn/longhorn-r1-storageclass.yaml

6. Expose the UI over HTTPS#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" longhorn/httproute.yaml | kubectl apply -f -

7. Verify#

kubectl get sc                                     # longhorn (default) + longhorn-r1
kubectl -n longhorn-system get nodes.longhorn.io   # all "Ready" and schedulable
kubectl -n longhorn-system get pods | grep -v Running   # should list nothing abnormal
curl --resolve "longhorn.${LAB_DOMAIN}:443:192.168.56.200" "https://longhorn.${LAB_DOMAIN}/" -kSI | head -1

⚠️ The Longhorn UI has no authentication and can delete volumes: only expose it on a trusted network.

🔧 Under the hood#

Why longhorn-r1 (1 replica)#

The Vagrantfile attaches no extra disk: Longhorn shares the box's single virtual disk with the OS, the container images and etcd. Stacking 3-replica volumes on it triggers ReplicaSchedulingFailure (and DiskPressure evictions before that). 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. Here, by default, we stay on /var/lib/longhorn (the box'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 (a vb.customize ["createhd", …] / ["storageattach", …] block).
  2. Debian: partition, format and mount it persistently, then point defaultDataPath at it:
    sudo mkfs.ext4 -L longhorn /dev/sdb
    echo 'LABEL=longhorn /mnt/longhorn ext4 defaults 0 2' | sudo tee -a /etc/fstab
    sudo mkdir -p /mnt/longhorn && sudo mount -a
    
    helm upgrade longhorn longhorn/longhorn -n longhorn-system \
      --reuse-values --set defaultSettings.defaultDataPath=/mnt/longhorn
    
    No mount-propagation patch is needed — that was a Talos constraint.

✅ Verify#

vagrant ssh k8s-w1 -c 'systemctl is-active iscsid'   # active
vagrant ssh k8s-w1 -c 'lsmod | grep iscsi_tcp'       # module loaded
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

ℹ️ Longhorn ships an environment check script that audits every node (iSCSI, NFS, multipathd, kernel modules) — the fastest way to confirm the Debian prerequisites: curl -sSfL https://raw.githubusercontent.com/longhorn/longhorn/v1.12.0/scripts/environment_check.sh | bash

🌐 Access#

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

kubectl apply -f longhorn/httproute.yaml

🌐 Domain: the manifest carries the neutral domain 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/lab\.example\.io/kubeadm.lab.my-domain.tld/g' \
  longhorn/httproute.yaml | kubectl apply -f -

(see ../README.md).

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

The wildcard cert *.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#

  • defaultReplicaCount > number of storage nodes → volumes stuck Degraded, forever. longhorn-up.sh aligns it on the nodes it counts; if you install the chart by hand, set it yourself (with 1 worker, 1).
  • 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
    
  • iscsid stopped (or a node built outside kubeadm/provision.sh) → CSI pods in CrashLoopBackOff, iscsiadm: not found / Failed to execute iscsiadm. Fix on the node: sudo apt-get install -y open-iscsi && sudo systemctl enable --now iscsid.
  • multipath-tools (multipathd): Debian 13 does not install it, and that is exactly why Longhorn works out of the box here. If you install it for something else, multipathd grabs Longhorn's block devices and volumes fail to attach (failed to get devicemapper). Blacklist them in /etc/multipath.conf (devices { device { vendor "IET" ... } }) or leave the package out.
  • Shared disk: Longhorn on /var/lib/longhorn eats the same filesystem as the OS, the container images and etcd → watch for DiskPressure, prefer longhorn-r1, or move to a dedicated disk (above).
  • vagrant destroy of a worker destroys its replicas. On longhorn (3 replicas) Longhorn rebuilds elsewhere; on longhorn-r1 (1 replica) the data is gone. Drain and let Longhorn rebuild before removing a node that stores anything you care about.
  • Uninstall: flip the Longhorn setting deleting-confirmation-flag to true before helm uninstall, otherwise the deletion hangs forever.

📚 References#

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

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 un par worker, plafonné à 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 : namespace, chart + les deux StorageClass + HTTPRoute
values.yaml Valeurs Helm : defaultDataPath, defaultReplicaCount, persistence.defaultClass: true
longhorn-r1-storageclass.yaml StorageClass socle longhorn-r1 (1 réplica bloc)
httproute.yaml HTTPRoute HTTPS longhorn.lab.example.iolonghorn-frontend:80 sur main-gateway

📋 Prérequis#

Prérequis Pourquoi Vérifier
iSCSI sur chaque nodekubeadm : paquets open-iscsi + nfs-common, iscsid actif, module iscsi_tcp, posés par kubeadm/provision.sh. Talos : extensions iscsi-tools + util-linux-tools CUITES dans l'image d'installation (cf. schematic.yaml), vérifiées par longhorn-up.sh longhorn-manager et le plugin CSI appellent iscsiadm pour attacher les volumes. Sans lui, les pods CSI partent en CrashLoopBackOff avec iscsiadm: not found kubeadm : vagrant ssh k8s-w1 -c 'systemctl is-active iscsid' · Talos : talosctl -n <ip> get extensions
Montage kubelet rshared sur /var/lib/longhornTalos uniquement (patch-longhorn.yaml, appliqué par longhorn-up.sh) le kubelet Talos tourne dans un conteneur, sans propagation de montage bidirectionnelle talosctl -n <ip> get mc -o yaml | grep /var/lib/longhorn
talosctl dans le PATHTalos uniquement vérification des extensions + patch du montage talosctl version --client
helm dans le PATH le chart helm version
Namespace longhorn-system en PodSecurity privileged les pods Longhorn sont privilégiés (iSCSI, hostPath) — posé par longhorn-up.sh 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

ℹ️ Les deux prérequis lourds n'existent que sur Talos — c'est ce qui fait de cet addon le plus dépendant de la distribution du dépôt :

Talos kubeadm / Debian
Extensions système iscsi-tools + util-linux-tools, cuites dans l'image de l'installeur. Un node sans elles n'est pas réparable à chaud — il faut le réinstaller ou l'upgrader vers une nouvelle ref Image Factory. longhorn-up.sh ne peut que vérifier (talosctl get extensions) et refuser d'aller plus loin. Deux paquets apt. kubeadm/provision.sh fait apt-get install -y open-iscsi nfs-common, systemctl enable --now iscsid et charge iscsi_tcp (/etc/modules-load.d/iscsi.conf) sur chaque node, au provisioning. Rien à vérifier, rien à cuire.
Montage kubelet rshared sur /var/lib/longhorn, appliqué par talosctl patch mc (à chaud, sans reboot) : le kubelet Talos tourne dans un conteneur et n'a pas la propagation de montage bidirectionnelle. /var/lib/longhorn est un dossier ordinaire du système de fichiers racine, et le kubelet tourne directement sur l'hôte : la propagation de montage est déjà bonne. Rien à patcher.

Conséquence : sur Talos, longhorn-up.sh exige talosctl et fait 5 étapes ; sur kubeadm, il en fait 3. Les fichiers patch-longhorn.yaml et schematic.yaml ne servent que sur Talos.

⚡ Installation#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> longhorn     # <distro> = talos | kubeadm

Version épinglée : chart Longhorn 1.12.0.

./longhorn/longhorn-up.sh <distro>

Idempotent : relançable sans casse (helm upgrade --install). Il couvre les deux étapes ci-dessous. Il compte les workers planifiables sur le cluster réel (plutôt que de faire confiance à WORKERS de lab.env, qui n'exprime qu'une intention) et aligne le nombre de réplicas bloc dessus, plafonné à 3. REPLICAS=… force la valeur, LONGHORN_VERSION=… surcharge la version du chart.

ℹ️ Avec WORKERS=0, les control planes sont déteintés (UNTAINT_CP=auto) et deviennent les seuls nodes de stockage : le script les compte au lieu de s'arrêter.

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

ℹ️ Un cluster kubeadm n'applique aucun niveau PodSecurity par défaut : ces étiquettes ne changent rien aujourd'hui. On les garde parce qu'elles documentent l'intention et gardent le namespace fonctionnel si le cluster est durci plus tard (--admission-control-config-file sur l'apiserver).

2. 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 longhorn/values.yaml
kubectl -n longhorn-system rollout status deploy/longhorn-driver-deployer
kubectl apply -f longhorn/longhorn-r1-storageclass.yaml

🧬 Talos vs kubeadm#

Le prérequis iSCSI n'est pas au même endroit selon la distribution — c'est LA différence structurante de ce composant (LONGHORN_PREP_REQUIRED dans les profils).

Talos kubeadm
iSCSI (iscsiadm) extension système iscsi-tools + util-linux-tools, CUITE dans l'image d'installation (longhorn/schematic.yaml → image factory, INSTALLER_IMAGE dans lab.env). Un node sans elles est irrécupérable à chaud : les pods CSI partent en CrashLoopBackOff (iscsiadm: not found) paquet : apt-get install -y open-iscsi nfs-common + systemctl enable --now iscsid + module iscsi_tcp, posés par kubeadm/provision.sh au provisioning
Propagation de montage montage kubelet rshared sur /var/lib/longhorn à appliquer (longhorn/patch-longhorn.yaml, talosctl patch mc, à chaud, sans reboot) — le kubelet Talos tourne dans un conteneur rien à faire : le kubelet tourne sur l'hôte, /var/lib/longhorn est un dossier ordinaire
Outils requis sur l'hôte kubectl, helm, talosctl kubectl, helm
Étapes du script 5 (extensions → patch → namespace → chart → HTTPRoute) 3 (namespace → chart → HTTPRoute)
Où vivent les données partition EPHEMERAL (perdue à un reset sans --preserve), disque ~20 Go partagé avec l'OS disque de la box, partagé avec l'OS et les images
Label PodSecurity privileged indispensable (défaut cluster baseline) documentation d'intention

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. (Talos uniquement) Vérifier les extensions iSCSI — avant tout le reste#

Une extension est cuite dans l'installeur : si elle manque, aucun kubectl ne rattrapera le coup, il faut réinstaller ou upgrader le node.

export TALOSCONFIG=../Vagrant-Talos/_out/talosconfig
for ip in 192.168.56.101 192.168.56.102 192.168.56.103; do
  echo "== $ip"; talosctl -n "$ip" get extensions | grep -E 'iscsi-tools|util-linux-tools'
done
# Manquantes ? générer l'image factory depuis longhorn/schematic.yaml, puis :
#   talosctl -n <ip> upgrade --image <image-factory> --preserve

2. (Talos uniquement) Appliquer le montage kubelet rshared#

talosctl -n 192.168.56.101 get mc -o yaml | grep -q /var/lib/longhorn \
  || talosctl -n 192.168.56.101 patch mc --patch @longhorn/patch-longhorn.yaml
# … à répéter sur chaque worker. Appliqué à chaud, sans reboot.

Sur kubeadm, les étapes 1 et 2 n'existent pas : open-iscsi est déjà installé et actif sur chaque node. Vérification facultative : vagrant ssh k8s-w1 -c 'systemctl is-active iscsid; lsmod | grep iscsi_tcp'

3. Namespace + PodSecurity privileged#

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. Compter les nodes de stockage (et NE PAS dépasser)#

defaultReplicaCount supérieur au nombre de nodes planifiables laisse tous les volumes en Degraded à vie.

REPLICAS=$(kubectl get nodes -l '!node-role.kubernetes.io/control-plane' --no-headers | wc -l | tr -d ' ')
[ "$REPLICAS" -eq 0 ] && REPLICAS=$(kubectl get nodes --no-headers | wc -l | tr -d ' ')
[ "$REPLICAS" -gt 3 ] && REPLICAS=3
echo "réplicas bloc : $REPLICAS"

5. Le chart + la StorageClass à 1 réplica#

helm repo add longhorn https://charts.longhorn.io && helm repo update longhorn
helm upgrade --install longhorn longhorn/longhorn \
  --namespace longhorn-system \
  --version 1.12.0 \
  --values longhorn/values.yaml \
  --set "defaultSettings.defaultReplicaCount=${REPLICAS}" \
  --set "persistence.defaultClassReplicaCount=${REPLICAS}" \
  --wait --timeout 10m
kubectl -n longhorn-system rollout status deploy/longhorn-driver-deployer --timeout=300s
kubectl apply -f longhorn/longhorn-r1-storageclass.yaml

6. Exposer l'UI en HTTPS#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" longhorn/httproute.yaml | kubectl apply -f -

7. Vérifier#

kubectl get sc                                     # longhorn (défaut) + longhorn-r1
kubectl -n longhorn-system get nodes.longhorn.io   # tous « Ready », schedulable
kubectl -n longhorn-system get pods | grep -v Running   # doit ne rien lister d'anormal
curl --resolve "longhorn.${LAB_DOMAIN}:443:192.168.56.200" "https://longhorn.${LAB_DOMAIN}/" -kSI | head -1

⚠️ L'UI Longhorn n'a aucune authentification et permet de supprimer des volumes : ne l'expose qu'en réseau de confiance.

🔧 Sous le capot#

Pourquoi longhorn-r1 (1 réplica)#

Le Vagrantfile n'attache aucun disque supplémentaire : Longhorn partage le disque unique de la box avec l'OS, les images conteneurs et etcd. Empiler des volumes 3-réplicas y déclenche des ReplicaSchedulingFailure (et des évictions DiskPressure avant ça). 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é. Ici, par défaut, on reste sur /var/lib/longhorn (disque unique de la box). 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 vb.customize ["createhd", …] / ["storageattach", …]).
  2. Debian : partitionner, formater et monter de façon persistante, puis pointer defaultDataPath dessus :
    sudo mkfs.ext4 -L longhorn /dev/sdb
    echo 'LABEL=longhorn /mnt/longhorn ext4 defaults 0 2' | sudo tee -a /etc/fstab
    sudo mkdir -p /mnt/longhorn && sudo mount -a
    
    helm upgrade longhorn longhorn/longhorn -n longhorn-system \
      --reuse-values --set defaultSettings.defaultDataPath=/mnt/longhorn
    
    Aucun patch de propagation de montage à faire — c'était une contrainte Talos.

✅ Vérifier#

vagrant ssh k8s-w1 -c 'systemctl is-active iscsid'   # active
vagrant ssh k8s-w1 -c 'lsmod | grep iscsi_tcp'       # module chargé
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

ℹ️ Longhorn livre un script de vérification d'environnement qui audite chaque node (iSCSI, NFS, multipathd, modules noyau) — le moyen le plus rapide de confirmer les prérequis Debian : curl -sSfL https://raw.githubusercontent.com/longhorn/longhorn/v1.12.0/scripts/environment_check.sh | bash

🌐 Accès#

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

kubectl apply -f longhorn/httproute.yaml

🌐 Domaine : le manifeste porte le domaine neutre 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/lab\.example\.io/kubeadm.lab.mon-domaine.tld/g' \
  longhorn/httproute.yaml | kubectl apply -f -

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

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

Cert wildcard *.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#

  • defaultReplicaCount > nombre de nodes de stockage → volumes coincés en Degraded, à vie. longhorn-up.sh l'aligne sur les nodes qu'il compte ; en installant le chart à la main, le faire soi-même (à 1 worker, mettre 1).
  • 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
    
  • iscsid arrêté (ou un node monté hors de kubeadm/provision.sh) → pods CSI en CrashLoopBackOff, erreurs iscsiadm: not found / Failed to execute iscsiadm. Sur le node : sudo apt-get install -y open-iscsi && sudo systemctl enable --now iscsid.
  • multipath-tools (multipathd) : Debian 13 ne l'installe pas, et c'est précisément pour ça que Longhorn fonctionne d'emblée ici. Si tu l'installes pour autre chose, multipathd s'approprie les devices bloc de Longhorn et les volumes ne s'attachent plus (failed to get devicemapper). Les blacklister dans /etc/multipath.conf (devices { device { vendor "IET" ... } }), ou ne pas installer le paquet.
  • Disque partagé : Longhorn sur /var/lib/longhorn consomme le même système de fichiers que l'OS, les images conteneurs et etcd → surveiller DiskPressure, préférer longhorn-r1, ou passer au disque dédié (ci-dessus).
  • vagrant destroy d'un worker détruit ses réplicas. Sur longhorn (3 réplicas) Longhorn reconstruit ailleurs ; sur longhorn-r1 (1 réplica) la donnée est perdue. Drainer et laisser Longhorn reconstruire avant de retirer un node qui stocke quelque chose d'important.
  • Désinstallation : passer le setting Longhorn deleting-confirmation-flag à true avant helm uninstall, sinon la suppression reste bloquée.

📚 Références#

📁local-path-storage/ — dynamic local storage (no Longhorn)

Deploys Rancher local-path-provisioner v0.0.36 and a default local-path StorageClass: PVs carved out of the worker's disk (/opt/local-path-provisioner). This is the lab's "no Longhorn" alternative — zero CSI driver, zero extra package on the nodes, two resources and provisioning just works.

🎯 Purpose#

A kubeadm cluster 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
Cluster with a working CNI (./kubeadm/cluster-up.sh or ./talos/cluster-up.sh, then ./platform-up.sh <distro>) 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 root filesystem PVs are hostPath directories under /opt, not sized volumes vagrant ssh k8s-w1 -c 'df -h /opt'

⚡ Install#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> local-path     # <distro> = talos | kubeadm
./local-path-storage/local-path-up.sh <distro>

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

🧬 Talos vs kubeadm#

One difference only, but a blocking one if ignored: the provisioning path (LOCAL_PATH_DIR in the profiles).

Talos kubeadm
PV path /var/local-path-provisioner /opt/local-path-provisioner (upstream path)
Why / and /etc are READ-ONLY, only /var is writable: a helper pod can create NOTHING under /opt /opt is writable, the helper pod creates it without asking
privileged PodSecurity labels required (cluster default baseline; helper pods mount hostPath) intent documentation

The versioned manifest carries the upstream path; local-path-up.sh substitutes it on the fly (sed) from the profile. Check afterwards: kubectl -n local-path-storage get cm local-path-config -o yaml | grep paths.

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. Pick the path for your distribution#

LOCAL_PATH_DIR=/var/local-path-provisioner    # Talos
# LOCAL_PATH_DIR=/opt/local-path-provisioner  # kubeadm (upstream path)

2. Apply the manifest with the path substituted#

sed "s#/opt/local-path-provisioner#${LOCAL_PATH_DIR}#g" \
    local-path-storage/local-path-storage.yaml | kubectl apply -f -
kubectl -n local-path-storage rollout status deploy/local-path-provisioner --timeout=120s

3. Check the effective config (the classic trap)#

kubectl -n local-path-storage get cm local-path-config -o jsonpath='{.data.config\.json}'; echo
kubectl get sc local-path -o jsonpath='{.metadata.annotations}'; echo   # is-default-class

4. End-to-end test: a PVC must bind as soon as a pod consumes it#

volumeBindingMode: WaitForFirstConsumer ⇒ a lone PVC stays Pending, which is expected.

kubectl create ns lp-test
kubectl -n lp-test apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: test }
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: local-path
  resources: { requests: { storage: 128Mi } }
---
apiVersion: v1
kind: Pod
metadata: { name: writer }
spec:
  containers:
    - name: c
      image: docker.io/library/busybox
      command: ["sh","-c","echo ok > /data/hello && sleep 3600"]
      volumeMounts: [{ name: d, mountPath: /data }]
  volumes: [{ name: d, persistentVolumeClaim: { claimName: test } }]
EOF
kubectl -n lp-test wait --for=condition=Ready pod/writer --timeout=120s
kubectl -n lp-test exec writer -- cat /data/hello
kubectl get pv | grep lp-test

5. Look at the directory on the node, then clean up#

# Talos   : talosctl -n <worker-ip> ls ${LOCAL_PATH_DIR}
# kubeadm : vagrant ssh k8s-w1 -c "sudo ls -l ${LOCAL_PATH_DIR}"
kubectl delete ns lp-test

🔧 Two deviations from the upstream manifest#

The vendored manifest (local-path-storage.yaml) starts from upstream v0.0.36 and keeps its /opt/local-path-provisioner path — on Debian 13 that directory is writable, so there is nothing to move:

# Change Why
1 Namespace local-path-storage in PodSecurity privileged The helper pods (creating/deleting the PV directories) mount hostPath. kubeadm enforces nothing at cluster level by default, so this label unblocks nothing today — it is kept because it states the intent and keeps the component working the day admission is hardened (Kyverno, a default AdmissionConfiguration, a managed cluster).
2 StorageClass local-path marked default (is-default-class) PVCs without a storageClassName use it automatically.

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":["/opt/local-path-provisioner"] } ] }
    
    You can map a path per node ("node":"k8s-w1"), for instance onto an extra disk mounted on that worker (/etc/fstab inside the VM — the nodes are plain Debian). 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.38 --restart=Never \
  --overrides='{"spec":{"volumes":[{"name":"d","persistentVolumeClaim":{"claimName":"lp-test"}}],"containers":[{"name":"c","image":"busybox:1.38","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 /opt/local-path-provisioner) are not cleaned up while PVCs still reference them. Delete the consuming workloads/PVCs first.

🧹 Uninstall#

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

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

📚 References#

📁local-path-storage/ — stockage local dynamique (sans Longhorn)

Déploie Rancher local-path-provisioner v0.0.36 et une StorageClass local-path par défaut : des PV taillés dans le disque du worker (/opt/local-path-provisioner). C'est l'alternative « sans Longhorn » du lab — zéro pilote CSI, zéro paquet supplémentaire sur les nodes, deux ressources et c'est provisionné.

🎯 À quoi ça sert#

Un cluster kubeadm 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 avec CNI opérationnel (./kubeadm/cluster-up.sh ou ./talos/cluster-up.sh, puis ./platform-up.sh <distro>) 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 le système de fichiers racine du worker les PV sont des dossiers hostPath sous /opt, pas des volumes taillés vagrant ssh k8s-w1 -c 'df -h /opt'

⚡ Installation#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> local-path     # <distro> = talos | kubeadm
./local-path-storage/local-path-up.sh <distro>

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

🧬 Talos vs kubeadm#

Une seule différence, mais bloquante si elle est ignorée : le chemin de provisionnement (LOCAL_PATH_DIR dans les profils).

Talos kubeadm
Chemin des PV /var/local-path-provisioner /opt/local-path-provisioner (chemin de l'amont)
Pourquoi / et /etc sont en LECTURE SEULE, seul /var est inscriptible : un helper-pod ne peut RIEN créer sous /opt /opt est inscriptible, le helper-pod le crée sans rien demander
Labels PodSecurity privileged indispensables (défaut cluster baseline ; les helper-pods montent du hostPath) documentation d'intention

Le manifeste versionné porte le chemin amont ; local-path-up.sh le substitue à la volée (sed) selon le profil. Vérifier après coup : kubectl -n local-path-storage get cm local-path-config -o yaml | grep paths.

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Choisir le chemin selon la distribution#

LOCAL_PATH_DIR=/var/local-path-provisioner    # Talos
# LOCAL_PATH_DIR=/opt/local-path-provisioner  # kubeadm (chemin amont)

2. Appliquer le manifeste, chemin substitué#

sed "s#/opt/local-path-provisioner#${LOCAL_PATH_DIR}#g" \
    local-path-storage/local-path-storage.yaml | kubectl apply -f -
kubectl -n local-path-storage rollout status deploy/local-path-provisioner --timeout=120s

3. Vérifier la config effective (le piège classique)#

kubectl -n local-path-storage get cm local-path-config -o jsonpath='{.data.config\.json}'; echo
kubectl get sc local-path -o jsonpath='{.metadata.annotations}'; echo   # is-default-class

4. Test de bout en bout : un PVC doit se lier dès qu'un pod le consomme#

volumeBindingMode: WaitForFirstConsumer ⇒ un PVC seul reste Pending, c'est normal.

kubectl create ns lp-test
kubectl -n lp-test apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: test }
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: local-path
  resources: { requests: { storage: 128Mi } }
---
apiVersion: v1
kind: Pod
metadata: { name: writer }
spec:
  containers:
    - name: c
      image: docker.io/library/busybox
      command: ["sh","-c","echo ok > /data/hello && sleep 3600"]
      volumeMounts: [{ name: d, mountPath: /data }]
  volumes: [{ name: d, persistentVolumeClaim: { claimName: test } }]
EOF
kubectl -n lp-test wait --for=condition=Ready pod/writer --timeout=120s
kubectl -n lp-test exec writer -- cat /data/hello
kubectl get pv | grep lp-test

5. Voir le dossier sur le node, puis nettoyer#

# Talos   : talosctl -n <worker-ip> ls ${LOCAL_PATH_DIR}
# kubeadm : vagrant ssh k8s-w1 -c "sudo ls -l ${LOCAL_PATH_DIR}"
kubectl delete ns lp-test

🔧 Deux écarts vs le manifeste upstream#

Le manifeste vendorisé (local-path-storage.yaml) part de l'upstream v0.0.36 et garde son chemin /opt/local-path-provisioner — sur Debian 13 ce dossier est inscriptible, il n'y a donc rien à déplacer :

# Modification Pourquoi
1 Namespace local-path-storage en PodSecurity privileged Les helper-pods (création/suppression des dossiers de PV) montent du hostPath. kubeadm n'applique rien au niveau cluster par défaut : ce label ne débloque donc rien aujourd'hui — on le garde parce qu'il documente l'intention et qu'il garde le composant fonctionnel le jour où l'admission est durcie (Kyverno, un AdmissionConfiguration par défaut, un cluster managé).
2 StorageClass local-path marquée par défaut (is-default-class) Les PVC sans storageClassName l'utilisent automatiquement.

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":["/opt/local-path-provisioner"] } ] }
    
    On peut mapper un chemin par node ("node":"k8s-w1"), par exemple vers un disque supplémentaire monté sur ce worker (/etc/fstab dans la VM — les nodes sont du Debian standard). 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.38 --restart=Never \
  --overrides='{"spec":{"volumes":[{"name":"d","persistentVolumeClaim":{"claimName":"lp-test"}}],"containers":[{"name":"c","image":"busybox:1.38","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 /opt/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 local-path-storage/local-path-storage.yaml

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

📚 Références#

🪣minio-s3/ — standalone MinIO (S3 + admin console)

An S3-compatible endpoint inside the cluster, in a single pod, with a full admin console (Pigsty fork). This is the simple version: one PVC, one replica. The distributed version lives in cluster/.

The PVC's StorageClass is MINIO_SC (default local-path). Set MINIO_SC=longhorn to put the bucket on replicated storage — see Which StorageClass? below.

🎯 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 *.lab.example.io):

Service URL Container port
S3 API https://minio.lab.example.io 9000
Admin console https://minio-console.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 PVC, 10 Gi (MINIO_SC) 4 local-path PVCs, 10 Gi (1/pod, 1/worker)
Erasure coding ❌ none EC:2
Resilience none on local-path; volume-level on longhorn 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
A StorageClass named by MINIO_SC (default local-path, ../local-path-storage/; or longhorn, ../longhorn/) the 10 Gi PVC behind /data; minio-up.sh bails out without it kubectl get storageclass "${MINIO_SC:-local-path}"
main-gateway + https listener (../envoy-gateway/) carries both HTTPRoutes kubectl get gateway -n envoy-gateway-system
Wildcard cert *.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.lab.example.io
openssl on the host generates the default root password openssl version

⚡ Install#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> minio     # <distro> = talos | kubeadm
./minio-s3/minio-up.sh <distro>
# Tunable credentials: MINIO_ROOT_USER (default "admin") / MINIO_ROOT_PASSWORD (generated)
MINIO_ROOT_PASSWORD='MyLabPass' ./minio-s3/minio-up.sh <distro>
# Bucket on replicated storage instead of node-local (Velero backup target):
MINIO_SC=longhorn ./minio-s3/minio-up.sh <distro>

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

💾 Which StorageClass? (MINIO_SC)#

MINIO_SC What it gives you Use it when
local-path (default) a hostPath directory on one node. Fastest, zero overhead, dies with its node sandbox: testing mc, SDKs, bucket policies
longhorn a replicated block volume; the pod is rescheduled elsewhere and finds its data this MinIO is a backup target (../velero/)

⚠️ A backup that dies with its node is not a backup. Velero writes both the object tarballs and the PV data here, so on local-path the loss of that single worker takes the cluster's only restore point with it. MINIO_SC=longhorn is the right default for a Velero target — accepting that Longhorn itself lives on those same workers, which is why an off-cluster copy stays the real answer.

ℹ️ storageClassName is immutable. Changing MINIO_SC on an already-installed MinIO is refused by the script with the two ways out (keep the current class, or delete deploy/minio + pvc/minio-data and lose the bucket content) — it is not silently ignored.

🧬 Talos vs kubeadm#

No distribution-specific behaviour for this component: same charts, same manifests, same values on both labs. The distribution argument only drives two things here: the default domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env / kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).

ℹ️ The only prerequisite is the StorageClass named by MINIO_SC — and if you leave it on local-path, its path does depend on the distribution (see ../local-path-storage/).

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. Check the storage prerequisite#

export MINIO_SC=local-path     # or longhorn (replicated — see "Which StorageClass?" above)
kubectl get sc "$MINIO_SC"     # otherwise: ./install.sh <distro> local-path | longhorn

2. Namespace + credentials Secret (generated once, never overwritten)#

kubectl create namespace minio-s3 --dry-run=client -o yaml | kubectl apply -f -
kubectl -n minio-s3 get secret minio-creds >/dev/null 2>&1 || \
kubectl -n minio-s3 create secret generic minio-creds \
  --from-literal=root-user=admin \
  --from-literal=root-password="$(openssl rand -base64 18 | tr -d '/+=' | head -c 24)"

3. Deployment + Service + HTTPRoutes (domain substituted)#

sed -e "s/lab\.example\.io/${LAB_DOMAIN}/g" \
    -e "s#^\( *storageClassName: \).*#\1${MINIO_SC}#" minio-s3/minio-s3.yaml | kubectl apply -f -
kubectl -n minio-s3 rollout status deploy/minio --timeout=180s
kubectl -n minio-s3 get pvc minio-data     # Bound, on the class you asked for

4. Read the credentials back#

kubectl -n minio-s3 get secret minio-creds -o jsonpath='{.data.root-user}' | base64 -d; echo
kubectl -n minio-s3 get secret minio-creds -o jsonpath='{.data.root-password}' | base64 -d; echo

5. Verify the S3 API and the console#

kubectl -n minio-s3 get httproute
curl --resolve "minio.${LAB_DOMAIN}:443:192.168.56.200" "https://minio.${LAB_DOMAIN}/minio/health/live" -kSI | head -1
# mc client (from the host):
mc alias set lab "https://minio.${LAB_DOMAIN}" <user> <pass> --insecure
mc mb lab/demo --insecure && mc ls lab --insecure

🔧 What the script does#

  1. Checks kubectl, the apiserver and the presence of the ${MINIO_SC:-local-path} StorageClass, then that an existing minio-data PVC is not on a different class (immutable field).
  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 with the domain and the StorageClass substituted: 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.lab.example.io:443:192.168.56.200 \
  https://minio.lab.example.io/minio/health/ready      # 200

🌐 Access#

What How
Admin console https://minio-console.lab.example.io
S3 API https://minio.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.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.
  • On local-path (the default) there is no resilience at all. A 1-replica Deployment + 1 node-local PVC: if the worker hosting the PV dies, the objects are gone. Three ways out, by increasing cost: MINIO_SC=longhorn (replicated volume, still one MinIO pod), cluster/ (4 drives, EC:2 — MinIO replicates on its own), or a copy off-cluster.
  • MINIO_SC=longhorn protects the volume, not the whole failure domain. Longhorn replicas live on the same workers as everything else, so a vagrant destroy still takes the bucket — including any Velero backup stored in it. Treat it as protection against one node dying, not as an off-site backup.
  • The 10 Gi of the PVC is a real limit on longhorn, but not on local-path. 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. Longhorn, by contrast, enforces the 10 Gi: MinIO gets ENOSPC and returns S3 errors instead of hurting the node. So when this MinIO is a Velero target, keep an eye on the bucket against the retention you configured (ttl in ../velero/schedule.yaml) — and raise the storage: request in minio-s3.yaml before it bites (Longhorn allows expansion).
  • 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.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#

🪣minio-s3/ — MinIO standalone (S3 + console d'admin)

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, un replica. La version distribuée est dans cluster/.

La StorageClass du PVC est MINIO_SC (défaut local-path). Mets MINIO_SC=longhorn pour poser le bucket sur du stockage répliqué — cf. Quelle StorageClass ? plus bas.

🎯 À 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 *.lab.example.io) :

Service URL Port conteneur
API S3 https://minio.lab.example.io 9000
Console admin https://minio-console.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 10 Gi (MINIO_SC) 4 PVC local-path 10 Gi (1/pod, 1/worker)
Erasure coding ❌ aucun EC:2
Résilience nulle sur local-path ; au niveau du volume sur longhorn 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
Une StorageClass nommée par MINIO_SC (défaut local-path, ../local-path-storage/ ; ou longhorn, ../longhorn/) le PVC 10 Gi de /data ; minio-up.sh s'arrête sans elle kubectl get storageclass "${MINIO_SC:-local-path}"
main-gateway + écouteur https (../envoy-gateway/) porte les deux HTTPRoute kubectl get gateway -n envoy-gateway-system
Cert wildcard *.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.lab.example.io
openssl sur l'hôte génère le mot de passe root par défaut openssl version

⚡ Installation#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> minio     # <distro> = talos | kubeadm
./minio-s3/minio-up.sh <distro>
# Identifiants réglables : MINIO_ROOT_USER (défaut « admin ») / MINIO_ROOT_PASSWORD (généré)
MINIO_ROOT_PASSWORD='MonPassLab' ./minio-s3/minio-up.sh <distro>
# Bucket sur stockage répliqué plutôt que node-local (cible de backup Velero) :
MINIO_SC=longhorn ./minio-s3/minio-up.sh <distro>

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

💾 Quelle StorageClass ? (MINIO_SC)#

MINIO_SC Ce que ça donne À utiliser quand
local-path (défaut) un dossier hostPath sur un node. Le plus rapide, zéro surcoût, meurt avec son node bac à sable : tester mc, des SDK, des politiques de bucket
longhorn un volume bloc répliqué ; le pod est replanifié ailleurs et retrouve ses données ce MinIO est une cible de backup (../velero/)

⚠️ Un backup qui meurt avec son node n'est pas un backup. Velero écrit ici à la fois les tarballs d'objets et les données des PV : sur local-path, la perte de cet unique worker emporte le seul point de restauration du cluster. MINIO_SC=longhorn est le bon défaut pour une cible Velero — en acceptant que Longhorn vive lui aussi sur ces mêmes workers, ce qui fait qu'une copie hors cluster reste la vraie réponse.

ℹ️ storageClassName est immuable. Changer MINIO_SC sur un MinIO déjà installé est refusé par le script, avec les deux sorties possibles (garder la classe actuelle, ou supprimer deploy/minio + pvc/minio-data et perdre le contenu du bucket) — ce n'est pas ignoré en silence.

🧬 Talos vs kubeadm#

Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes, mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou ../Vagrant-KubeADM/).

ℹ️ Le seul prérequis est la StorageClass nommée par MINIO_SC — et si tu la laisses sur local-path, son chemin, lui, dépend de la distribution (cf. ../local-path-storage/).

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Vérifier le prérequis stockage#

export MINIO_SC=local-path     # ou longhorn (répliqué — cf. « Quelle StorageClass ? » plus haut)
kubectl get sc "$MINIO_SC"     # sinon : ./install.sh <distro> local-path | longhorn

2. Namespace + Secret d'identifiants (généré une fois, jamais écrasé)#

kubectl create namespace minio-s3 --dry-run=client -o yaml | kubectl apply -f -
kubectl -n minio-s3 get secret minio-creds >/dev/null 2>&1 || \
kubectl -n minio-s3 create secret generic minio-creds \
  --from-literal=root-user=admin \
  --from-literal=root-password="$(openssl rand -base64 18 | tr -d '/+=' | head -c 24)"

3. Déploiement + Service + HTTPRoutes (domaine et StorageClass substitués)#

sed -e "s/lab\.example\.io/${LAB_DOMAIN}/g" \
    -e "s#^\( *storageClassName: \).*#\1${MINIO_SC}#" minio-s3/minio-s3.yaml | kubectl apply -f -
kubectl -n minio-s3 rollout status deploy/minio --timeout=180s
kubectl -n minio-s3 get pvc minio-data     # Bound, sur la classe demandée

4. Récupérer les identifiants#

kubectl -n minio-s3 get secret minio-creds -o jsonpath='{.data.root-user}' | base64 -d; echo
kubectl -n minio-s3 get secret minio-creds -o jsonpath='{.data.root-password}' | base64 -d; echo

5. Vérifier l'API S3 et la console#

kubectl -n minio-s3 get httproute
curl --resolve "minio.${LAB_DOMAIN}:443:192.168.56.200" "https://minio.${LAB_DOMAIN}/minio/health/live" -kSI | head -1
# client mc (depuis l'hôte) :
mc alias set lab "https://minio.${LAB_DOMAIN}" <user> <pass> --insecure
mc mb lab/demo --insecure && mc ls lab --insecure

🔧 Ce que fait le script#

  1. Vérifie kubectl, l'apiserver et la présence de la StorageClass ${MINIO_SC:-local-path}, puis qu'un PVC minio-data existant n'est pas sur une autre classe (champ immuable).
  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 avec le domaine et la StorageClass substitués : 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.lab.example.io:443:192.168.56.200 \
  https://minio.lab.example.io/minio/health/ready      # 200

🌐 Accès#

Quoi Comment
Console admin https://minio-console.lab.example.io
API S3 https://minio.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.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.
  • Sur local-path (le défaut), aucune résilience. Deployment 1 replica + 1 PVC node-local : si le worker qui héberge le PV meurt, les objets sont perdus. Trois sorties, par coût croissant : MINIO_SC=longhorn (volume répliqué, toujours un seul pod MinIO), cluster/ (4 drives, EC:2 — MinIO se réplique tout seul), ou une copie hors cluster.
  • MINIO_SC=longhorn protège le volume, pas le domaine de panne. Les replicas Longhorn vivent sur les mêmes workers que le reste : un vagrant destroy emporte quand même le bucket — y compris le backup Velero qui s'y trouve. À considérer comme une protection contre la mort d'un node, pas comme une sauvegarde hors site.
  • Les 10 Gi du PVC sont une vraie limite sur longhorn, mais pas sur local-path. 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. Longhorn, à l'inverse, fait respecter les 10 Gi : MinIO reçoit ENOSPC et renvoie des erreurs S3 au lieu d'abîmer le node. Quand ce MinIO est une cible Velero, garde donc un œil sur le bucket face à la rétention configurée (ttl dans ../velero/schedule.yaml) — et augmente la requête storage: de minio-s3.yaml avant que ça morde (Longhorn autorise l'expansion).
  • 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.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#

🧺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 default shipped by both labs (their 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 *.lab.example.io):

Service URL Port
S3 API https://minio-cluster.lab.example.io 9000
Admin console https://minio-cluster-console.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.lab.example.io

⚠️ Set WORKERS=4 (or more) in lab.env before building the cluster. The lab's 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#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> minio-cluster     # <distro> = talos | kubeadm
./minio-s3/cluster/minio-cluster-up.sh <distro>
# 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).

🧬 Talos vs kubeadm#

No distribution-specific behaviour for this component: same charts, same manifests, same values on both labs. The distribution argument only drives two things here: the default domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env / kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).

ℹ️ A topology constraint, not a distribution one: anti-affinity pins 1 pod per node, so 4 Ready workers are required (otherwise pods stay Pending).

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. Check the prerequisites (storage + worker count)#

kubectl get sc local-path
kubectl get nodes -l '!node-role.kubernetes.io/control-plane' --no-headers | grep -c ' Ready '   # ≥ 4

2. Namespace + credentials Secret#

kubectl create namespace minio-cluster --dry-run=client -o yaml | kubectl apply -f -
kubectl -n minio-cluster get secret minio-creds >/dev/null 2>&1 || \
kubectl -n minio-cluster create secret generic minio-creds \
  --from-literal=root-user=admin \
  --from-literal=root-password="$(openssl rand -base64 18 | tr -d '/+=' | head -c 24)"

3. 4-node StatefulSet + Services + HTTPRoutes#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" minio-s3/cluster/minio-cluster.yaml | kubectl apply -f -
kubectl -n minio-cluster rollout status statefulset/minio --timeout=300s
kubectl -n minio-cluster get pods -o wide      # one pod per node

4. Verify erasure coding#

mc alias set labc "https://minio-cluster.${LAB_DOMAIN}" <user> <pass> --insecure
mc admin info labc --insecure        # 4 drives online, tolerates ~2 nodes down

5. Exercise fault tolerance (optional, spectacular)#

kubectl -n minio-cluster delete pod minio-0     # the StatefulSet recreates it
mc admin info labc --insecure                   # the service stays available

🔧 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.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.lab.example.io
S3 API https://minio-cluster.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.lab.example.io <user> <pass> --insecure
mc alias set clu https://minio-cluster.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.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.

🧺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 livré par les deux labs (leur 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 *.lab.example.io) :

Service URL Port
API S3 https://minio-cluster.lab.example.io 9000
Console admin https://minio-cluster-console.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.lab.example.io

⚠️ Passer WORKERS=4 (ou plus) dans lab.env avant de monter le cluster. Le lab.env.example du lab 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#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> minio-cluster     # <distro> = talos | kubeadm
./minio-s3/cluster/minio-cluster-up.sh <distro>
# 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).

🧬 Talos vs kubeadm#

Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes, mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou ../Vagrant-KubeADM/).

ℹ️ Contrainte de topologie, pas de distribution : l'anti-affinité impose 1 pod par node, donc 4 workers Ready sont nécessaires (sinon des pods restent Pending).

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Vérifier les prérequis (stockage + nombre de workers)#

kubectl get sc local-path
kubectl get nodes -l '!node-role.kubernetes.io/control-plane' --no-headers | grep -c ' Ready '   # ≥ 4

2. Namespace + Secret d'identifiants#

kubectl create namespace minio-cluster --dry-run=client -o yaml | kubectl apply -f -
kubectl -n minio-cluster get secret minio-creds >/dev/null 2>&1 || \
kubectl -n minio-cluster create secret generic minio-creds \
  --from-literal=root-user=admin \
  --from-literal=root-password="$(openssl rand -base64 18 | tr -d '/+=' | head -c 24)"

3. StatefulSet 4 nœuds + Services + HTTPRoutes#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" minio-s3/cluster/minio-cluster.yaml | kubectl apply -f -
kubectl -n minio-cluster rollout status statefulset/minio --timeout=300s
kubectl -n minio-cluster get pods -o wide      # 1 pod par node

4. Vérifier l'erasure coding#

mc alias set labc "https://minio-cluster.${LAB_DOMAIN}" <user> <pass> --insecure
mc admin info labc --insecure        # 4 drives online, tolère ~2 nœuds down

5. Éprouver la tolérance de panne (optionnel, spectaculaire)#

kubectl -n minio-cluster delete pod minio-0     # le StatefulSet le recrée
mc admin info labc --insecure                   # le service reste disponible

🔧 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.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.lab.example.io
API S3 https://minio-cluster.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.lab.example.io <user> <pass> --insecure
mc alias set clu https://minio-cluster.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.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.

velero/ — backup and restore of the cluster (objects and Longhorn PV data) to MinIO

Velero writes two things into the same S3 bucket: the Kubernetes objects (every manifest, namespaced and cluster-scoped) and the content of the persistent volumes — Longhorn included — through File System Backup. A namespace deleted by mistake, PVC and data alike, comes back with a single velero restore create.

🌐 Unlike most add-ons here, nothing in this directory carries the neutral domain: Velero exposes no UI and reaches MinIO through the in-cluster Service. There is nothing to substitute, and kubectl apply -f velero/schedule.yaml by hand is exactly what the script does.

🎯 Purpose#

Backing up a Kubernetes cluster is two problems that people keep conflating:

What Where it lives How Velero takes it
The objects — Deployments, Secrets, PVC definitions, CRDs, ClusterRoles… etcd listed through the API server, written as one tarball per backup into the bucket
The data — the bytes inside a Longhorn (or local-path) volume the workers' disks File System Backup: the node-agent DaemonSet reads the volume where the kubelet already mounted it and uploads it to the same bucket with kopia

Restoring only the first gives you a cluster full of empty PVCs. This component does both, and does them in one place — the velero bucket of the lab's MinIO.

Two costs, two cadences#

The distinction above is not academic: it is why there are two schedules rather than one.

Schedule Cron What it writes TTL Cost per tick
hourly-objects 0 * * * * the objects only 48h one tarball, a few MB, seconds
daily-full 0 2 * * * objects + every pod volume 168h the node-agent re-reads every volume

So: a ~1-hour RPO on "someone deleted a Deployment / a Secret / a whole namespace", and a ~24-hour RPO on "the bytes inside a PVC are wrong". Backing the manifests up hourly is nearly free; doing the same with the volume data would keep the node-agent busy around the clock for a lab that changes a few files a day.

⚠️ An hourly-objects backup contains the PVC and PV definitions, not their contents. Restoring from one recreates the PVCs empty (Longhorn provisions fresh volumes). It is the right choice for "undo my last kubectl apply", and the wrong one for "get my data back" — for that, restore the latest daily-full. velero backup describe <name> spells out which kind you are looking at, and the volume count in kubectl -n velero get podvolumebackups is zero for every hourly one.

The setup in one sentence#

deployNodeAgent: true + defaultVolumesToFsBackup: true: every pod volume is backed up without any per-workload annotation, and the object store is http://minio.minio-cluster.svc.cluster.local:9000, reached through the pod network only.

Why File System Backup rather than CSI snapshots#

CSI snapshot File System Backup (what we use)
Where the copy lands inside Longhorn, on the very worker disks being protected in MinIO, i.e. outside the volume being protected
Survives vagrant destroy ❌ no ✅ yes
Extra prerequisites the external-snapshotter controller + a VolumeSnapshotClassneither is installed by this lab none beyond the node-agent DaemonSet
Storage classes covered those with a CSI driver that supports snapshots all of themlonghorn, longhorn-r1, local-path
Cost instant (copy-on-write) reads and uploads the bytes, deduplicated by kopia

A snapshot is a rollback mechanism, a backup is a copy elsewhere mechanism. On a lab whose whole point is that it gets destroyed and rebuilt, only the second one is worth the name.

ℹ️ Longhorn also has its own S3 backup target, configured in its UI. It is a fine tool and it covers Longhorn volumes only — not the manifests, not local-path. The two coexist without conflict; this component deliberately covers the whole cluster instead.

Files#

File Purpose
velero-up.sh the install: MinIO bucket + scoped user, namespace, credentials Secret, chart, Schedules
values.yaml Helm values: the AWS plugin init container, the BackupStorageLocation, FSB defaults, node-agent
schedule.yaml two Schedules — hourly-objects (objects, TTL 48h) and daily-full (objects + PV data, 02:00, TTL 7 days)

📋 Prerequisites#

Prerequisite Why Verify
A MinIO in the cluster — ../minio-s3/cluster/ preferred, ../minio-s3/ accepted the object store. velero-up.sh picks minio-cluster first, falls back to minio-s3, and VELERO_MINIO_NS=… overrides both kubectl -n minio-cluster get svc minio
Namespace velero with PodSecurity privileged the node-agent mounts the kubelet's pod directory (hostPath), which baseline forbids — applied by velero-up.sh kubectl get ns velero --show-labels
helm, curl, openssl in PATH the chart, the mc download, the generated access key helm version
mc (MinIO client) creates the bucket and the scoped user — downloaded automatically if missing mc --version
velero CLI — optional everything here is plain CRDs, but a restore without the CLI is painful velero version --client-only
A storage add-on, if you want volume data to back up with no PVC in the cluster, Velero backs up objects only — which is not a failure, just an empty half kubectl get pvc -A

ℹ️ No LoadBalancer IP, no Gateway, no DNS record. Velero talks to minio.<ns>.svc.cluster.local:9000, a ClusterIP: this add-on behaves identically whether the LoadBalancer IPs of the lab come from Cilium's L2 announcer or from ../metallb/, and it keeps working when neither is installed.

⚡ Install#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> velero       # <distro> = talos | kubeadm

Pinned versions: chart Velero 12.1.0 (app v1.18.1), plugin velero/velero-plugin-for-aws:v1.14.2.

./velero/velero-up.sh <distro>

Idempotent: helm upgrade --install + kubectl apply, and the MinIO user keeps its existing access key (re-minting it on every run would silently 403 every upload).

Variable Default Effect
VELERO_VERSION 12.1.0 chart version
VELERO_AWS_PLUGIN_VERSION v1.14.2 object store plugin — must match the Velero minor (v1.14.x ↔ v1.18.x)
VELERO_MINIO_NS detected which MinIO to target (minio-cluster, then minio-s3)
VELERO_BUCKET velero bucket name — created if absent
VELERO_S3_USER velero MinIO user, scoped to that bucket alone
VELERO_NS velero Velero's own namespace
MC_ALIAS labminio the transient mc alias for the port-forward. Must start with a lettermc rejects the name otherwise (this is why the older _lab broke the install), and it deliberately avoids plain lab so it never clobbers your own alias

🧬 Talos vs kubeadm#

No divergence in the install: the same 4 steps, the same chart, the same values on both labs. The single line that would break on Talos if it were left implicit is carried by a profile variable, VELERO_POD_VOLUME_PATH.

Talos kubeadm
node-agent hostPath /var/lib/kubelet/pods — works because the kubelet root dir sits under /var, the only writable filesystem (/ and /usr are read-only) /var/lib/kubelet/pods — the upstream path on an ordinary filesystem, nothing special about it
privileged PodSecurity label on velero required: baseline is enforced cluster-wide and forbids hostPath volumes. Without the label the DaemonSet exists and creates no pod — silently documents the need; enforces nothing today
Host tooling kubectl, helm, curl, openssl (mc auto-downloaded) identical — no talosctl, Velero never touches the node configuration
Script steps 4 4
What ends up in the backup identical: the objects, plus every pod volume that is mounted identical

Why a variable for a value that is the same on both sides: it turns "the chart default happens to work on Talos" into a checked fact. An installer image that moved the kubelet root (--root-dir) would need exactly one line changed, in lib/profiles/talos.sh, and not a single if in this component — the rule this repository applies to every divergence (see ../README.md).

🎓 Guided walkthrough (step by step)#

The commands below are exactly what velero-up.sh does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig      # or ../Vagrant-KubeADM/kubeconfig
export MINIO_NS=minio-cluster                      # or minio-s3

1. Namespace + privileged PodSecurity#

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

⚠️ On Talos, skipping this label is the classic silent failure: kubectl -n velero get ds shows DESIRED 6 / READY 0 and no pod at all. The reason is in the ReplicaSet/DaemonSet events, nowhere else: violates PodSecurity "baseline": hostPath volumes.

2. The bucket and a MinIO user scoped to it#

Never hand a cluster-admin agent the MinIO root credentials.

ROOTPW=$(kubectl -n "$MINIO_NS" get secret minio-creds -o jsonpath='{.data.root-password}' | base64 -d)
kubectl -n "$MINIO_NS" port-forward svc/minio 19010:9000 &
mc alias set labminio http://127.0.0.1:19010 admin "$ROOTPW"

mc mb --ignore-existing labminio/velero
cat > /tmp/velero-policy.json <<'JSON'
{ "Version":"2012-10-17","Statement":[
  {"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::velero","arn:aws:s3:::velero/*"]} ]}
JSON
mc admin policy create labminio velero-rw /tmp/velero-policy.json
SK=$(openssl rand -base64 21 | tr -d '/+=' | head -c 28)
mc admin user add labminio velero "$SK"
mc admin policy attach labminio velero-rw --user velero
kill %1                                  # the port-forward has done its job

3. The credentials Secret — one cloud key, an AWS credentials file#

kubectl -n velero create secret generic velero-s3 \
  --from-literal=cloud="$(printf '[default]\naws_access_key_id=velero\naws_secret_access_key=%s\n' "$SK")" \
  --dry-run=client -o yaml | kubectl apply -f -

4. The chart#

helm repo add vmware-tanzu https://vmware-tanzu.github.io/helm-charts && helm repo update vmware-tanzu
# values.yaml defaults to minio-cluster; point it at yours if it lives elsewhere:
sed "s#minio\.minio-cluster\.svc#minio.${MINIO_NS}.svc#" velero/values.yaml > /tmp/velero-values.yaml
helm upgrade --install velero vmware-tanzu/velero \
  --namespace velero \
  --version 12.1.0 \
  --values /tmp/velero-values.yaml \
  --set nodeAgent.podVolumePath=/var/lib/kubelet/pods \
  --wait --timeout 10m
kubectl -n velero rollout status deploy/velero
kubectl -n velero rollout status ds/node-agent

5. Velero's own verdict on the bucket#

helm install succeeding proves nothing about the credentials. This does:

kubectl -n velero get backupstoragelocation default
# NAME      PHASE       LAST VALIDATED   AGE   DEFAULT
# default   Available   10s              1m    true

6. The recurring backups#

kubectl apply -f velero/schedule.yaml
kubectl -n velero get schedules      # hourly-objects + daily-full, with their crons

🔧 What the script does#

Step Action
[1/4] namespace velero + the three privileged PodSecurity labels
[2/4] MinIO: bucket velero, policy velero-rw, user velero scoped to it, then the velero-s3 Secret
[3/4] Helm chart with the resolved endpoint, then waits for BackupStorageLocation: Available
[4/4] kubectl apply -f schedule.yamlboth Schedules (hourly-objects, daily-full)

The Helm settings that matter#

Setting Value Why
initContainers[0] velero-plugin-for-aws:v1.14.2 the Velero image ships no provider plugin; without it the server loops on unable to locate ObjectStore plugin for aws
config.s3ForcePathStyle "true" MinIO serves a bucket as a path (minio:9000/velero), not as a subdomain
config.region us-east-1 MinIO ignores it, the AWS SDK refuses to sign without one
deployNodeAgent true no DaemonSet, no volume data — objects only
defaultVolumesToFsBackup true covers every pod volume with no per-workload annotation — and it is a server-level default, which is exactly why hourly-objects has to set it back to false explicitly
uploaderType kopia deduplicated and compressed; restic is legacy
snapshotsEnabled false no VolumeSnapshotLocation: there is no snapshot controller in this lab, and an AWS-provider VSL would only produce errors
defaultBackupTTL 168h 7 days — every byte lands on the workers' shared disk

The objects Velero creates#

Kind Role
BackupStorageLocation the bucket + its health (Available / Unavailable)
Schedule a Backup factory — one object per tick
Backup / Restore one run each; the tarball lives in the bucket, the object in etcd
PodVolumeBackup / PodVolumeRestore one per volume: this is where FSB progress and failures show up
BackupRepository the kopia repository, one per (namespace, storage location)

✅ Verify#

kubectl -n velero get backupstoragelocation default        # PHASE=Available
kubectl -n velero get pods                                 # velero + one node-agent PER NODE
kubectl -n velero get schedules                            # hourly-objects + daily-full

# The real proof: a backup that completes, with its volumes
velero backup create smoke --wait
velero backup describe smoke --details | sed -n '/Phase/p;/Item/p'
kubectl -n velero get podvolumebackups                     # one line per mounted volume, Completed

Check that the two cadences really differ — the count of PodVolumeBackups is the tell:

# objects only: expect ZERO PodVolumeBackups for this backup
velero backup create objs-only --snapshot-volumes=false --default-volumes-to-fs-backup=false --wait
kubectl -n velero get podvolumebackups -l velero.io/backup-name=objs-only    # "No resources found"

# objects + data: expect one PodVolumeBackup per mounted volume
velero backup create full-now --default-volumes-to-fs-backup --wait
kubectl -n velero get podvolumebackups -l velero.io/backup-name=full-now     # all Completed

Without the CLI, the same thing with kubectl only:

kubectl -n velero create -f - <<'EOF'
apiVersion: velero.io/v1
kind: Backup
metadata: { name: smoke, namespace: velero }
spec: { defaultVolumesToFsBackup: true, ttl: 168h }
EOF
kubectl -n velero get backups.velero.io smoke -o jsonpath='{.status.phase}{"\n"}'    # Completed

And the objects really landed in MinIO:

kubectl -n minio-cluster port-forward svc/minio 19010:9000 &
mc ls -r labminio/velero/backups/smoke/
kill %1

🧪 Scenario — delete a namespace with its Longhorn data, and get it back#

The demo that justifies the component. It needs a storage class (../longhorn/; local-path works too).

# 1. An application with a volume, and a byte we can recognise
kubectl create namespace demo-backup
kubectl apply -n demo-backup -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: data }
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: longhorn
  resources: { requests: { storage: 1Gi } }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: writer }
spec:
  replicas: 1
  selector: { matchLabels: { app: writer } }
  template:
    metadata: { labels: { app: writer } }
    spec:
      containers:
        - name: sh
          image: busybox:1.37
          command: ["sh", "-c", "sleep infinity"]
          volumeMounts: [{ name: data, mountPath: /data }]
      volumes:
        - { name: data, persistentVolumeClaim: { claimName: data } }
EOF
kubectl -n demo-backup rollout status deploy/writer
kubectl -n demo-backup exec deploy/writer -- sh -c 'echo "precious" > /data/proof.txt'

# 2. Back it up — the PVC definition AND the bytes
velero backup create demo-backup-1 --include-namespaces demo-backup --wait
kubectl -n velero get podvolumebackups           # one Completed line for the `data` volume

# 3. The disaster
kubectl delete namespace demo-backup
kubectl get pvc -n demo-backup                   # gone, volume included

# 4. The restore
velero restore create --from-backup demo-backup-1 --wait
kubectl -n demo-backup rollout status deploy/writer
kubectl -n demo-backup exec deploy/writer -- cat /data/proof.txt      # precious

# 5. Clean up
kubectl delete namespace demo-backup

ℹ️ Step 4 is where FSB shows itself: Velero recreates the PVC, then the node-agent injects a restore-wait init container into the pod, which blocks startup until the data has been written back. A pod stuck in Init:0/1 for a while is normal — watch kubectl -n velero get podvolumerestores.

🚑 Troubleshooting#

Symptom Cause Fix
BackupStorageLocation Unavailable wrong credentials, bucket missing, MinIO down kubectl -n velero logs deploy/velero | tail -30; re-run velero-up.sh (it fixes the bucket, the policy and the Secret)
Server logs unable to locate ObjectStore plugin for aws the init container never ran (values overridden, initContainers emptied) kubectl -n velero get deploy velero -o jsonpath='{.spec.template.spec.initContainers[*].image}'
node-agent DESIRED n / READY 0, no pod the namespace is not labelled privileged (Talos) kubectl label ns velero pod-security.kubernetes.io/enforce=privileged --overwrite
Backup PartiallyFailed, a PodVolumeBackup in Failed a volume the agent cannot read (hostPath, unmounted volume) kubectl -n velero get podvolumebackups -o wide, then the pod's own logs
Everything 403s after a re-install of MinIO MinIO lost the velero user, the Secret still holds the old key delete the Secret and re-run: kubectl -n velero delete secret velero-s3 && ./velero/velero-up.sh <distro>
Upload fails on a checksum/signature error some S3 implementations reject the SDK's default checksum add checksumAlgorithm: "" to the config: block of values.yaml (plugin README)
velero: command not found the CLI is optional and not installed use the kubectl forms above, or install it (see References)

⚠️ Pitfalls#

  • FSB only backs up a volume that a pod is mounting. A PVC bound to nothing — or bound to a pod that is scaled to zero — has its definition backed up and its content skipped, with no error. Scale the workload up before backing up data you care about, and check kubectl -n velero get podvolumebackups rather than trusting Phase: Completed on the Backup alone.
  • hostPath volumes are never backed up by FSB (by design). In this lab that mostly means the system DaemonSets, which is fine — but do not expect local-path node directories to come back through anything other than their PVC.
  • defaultVolumesToFsBackup: true also picks up emptyDirs. Prometheus' WAL and similar scratch volumes will be uploaded. Opt a pod out volume by volume:
    kubectl -n <ns> annotate pod <pod> backup.velero.io/backup-volumes-excludes=cache,tmp
    
  • Do not count on Velero to restore MinIO itself. The backup of the minio-cluster namespace lives in minio-cluster: if MinIO is gone, so is the backup. MinIO's own resilience is its erasure coding, not this component.
  • A restored LoadBalancer Service does not necessarily get the same IP. The announcer (Cilium L2 or MetalLB) re-allocates from the pool. If a DNS record points at .200, check where the Gateway landed after a full-cluster restore.
  • A full-cluster restore includes the velero namespace unless you exclude it. Into a fresh cluster, use velero restore create --from-backup <b> --exclude-namespaces velero, otherwise the restore fights the Velero that is running it.
  • The Schedule cron is read in the server's timezone (UTC in this lab), not your workstation's. 0 2 * * * is 13:00 in Nouméa, and hourly-objects fires on the UTC hour.
  • Restoring the newest backup is not always what you want. With two schedules the latest backup is almost always an hourly-objects one, which carries no volume data. Pick deliberately: kubectl -n velero get backups.velero.io --sort-by=.metadata.creationTimestamp then restore from the last daily-full-* if you need the PVC contents back.
  • kubectl get backups is AMBIGUOUS in this lab and silently answers about Longhorn. Three installed CRDs claim the Backup kind — longhorn.io, velero.io and postgresql.cnpg.io — and kubectl resolves the bare plural to Longhorn's. So kubectl -n velero get backups prints No resources found in velero namespace. while Velero's backups sit right there: a perfectly convincing false negative. Always qualify it: kubectl -n velero get backups.velero.io. podvolumebackups and schedules are unambiguous, which is what makes the trap so easy to walk into.
  • velero backup logs / describe --details fail from your workstation. They ask the API server for a presigned URL, which is minted against the BackupStorageLocation's endpoint — http://minio.<ns>.svc.cluster.local:9000, an in-cluster DNS name your host cannot resolve (dial tcp: lookup … no such host). The backup itself is fine. To read the logs or the error list, go through the bucket instead:
    kubectl -n <minio-ns> port-forward svc/minio 19010:9000 &
    mc alias set labminio http://127.0.0.1:19010 admin "$ROOTPW"
    mc cp labminio/velero/backups/<backup>/<backup>-results.gz - | gunzip | python3 -m json.tool
    
  • velero backup delete removes the objects from the bucket too; kubectl delete backup removes only the Kubernetes object and leaves the tarball orphaned in MinIO.
  • The TTLs are 48 h (hourly) and 7 days (daily). A lab left off for a fortnight comes back with an empty bucket — the GC runs on Velero's clock, not on how much you needed that backup.
  • 24 hourly ticks a day is 24 more Backup objects a day. At 48h TTL that settles around ~50 objects; kubectl -n velero get backups.velero.io gets noisy long before it gets expensive. Filter with -l velero.io/schedule-name=daily-full when you only care about the restorable-with-data ones.

🧹 Uninstall#

kubectl delete -f velero/schedule.yaml
helm uninstall velero -n velero
kubectl delete namespace velero          # also drops the CRs; the CRDs survive
kubectl get crd | sed -n '/velero.io/p' | awk '{print $1}' | xargs -r kubectl delete crd
# The bucket is NOT deleted: mc rb --force labminio/velero

📚 References#

velero/ — sauvegarde et restauration du cluster (objets et données des PV Longhorn) vers MinIO

Velero écrit deux choses dans le même bucket S3 : les objets Kubernetes (tous les manifestes, namespacés et cluster-scoped) et le contenu des volumes persistants — Longhorn compris — via le File System Backup. Un namespace supprimé par erreur, PVC et données incluses, revient avec un seul velero restore create.

🌐 Contrairement à la plupart des addons d'ici, aucun fichier de ce dossier ne porte le domaine neutre : Velero n'expose pas d'UI et joint MinIO par le Service interne au cluster. Il n'y a rien à substituer, et un kubectl apply -f velero/schedule.yaml à la main fait exactement ce que fait le script.

🎯 À quoi ça sert#

Sauvegarder un cluster Kubernetes, ce sont deux problèmes qu'on confond en permanence :

Quoi Où ça vit Comment Velero le prend
Les objets — Deployments, Secrets, définitions de PVC, CRD, ClusterRole… etcd listés via l'API server, écrits sous forme d'un tarball par sauvegarde dans le bucket
Les données — les octets à l'intérieur d'un volume Longhorn (ou local-path) le disque des workers File System Backup : le DaemonSet node-agent lit le volume là où le kubelet l'a déjà monté et l'envoie dans le même bucket avec kopia

Ne restaurer que le premier, c'est obtenir un cluster plein de PVC vides. Cet addon fait les deux, et les fait au même endroit — le bucket velero du MinIO du lab.

Deux coûts, deux cadences#

La distinction ci-dessus n'est pas théorique : c'est pour ça qu'il y a deux schedules et non un seul.

Schedule Cron Ce qu'il écrit TTL Coût par tick
hourly-objects 0 * * * * les objets seulement 48h un tarball, quelques Mo, des secondes
daily-full 0 2 * * * les objets + tous les volumes de pods 168h le node-agent relit chaque volume

Donc : un RPO d'environ 1 heure sur « quelqu'un a supprimé un Deployment / un Secret / un namespace entier », et un RPO d'environ 24 heures sur « les octets dans un PVC sont faux ». Sauvegarder les manifestes chaque heure est quasi gratuit ; faire pareil avec les données des volumes occuperait le node-agent en permanence pour un lab qui change quelques fichiers par jour.

⚠️ Une sauvegarde hourly-objects contient les définitions de PVC et de PV, pas leur contenu. Restaurer depuis l'une d'elles recrée les PVC vides (Longhorn provisionne des volumes neufs). C'est le bon choix pour « annuler mon dernier kubectl apply », et le mauvais pour « récupérer mes données » — pour ça, restaure le dernier daily-full. velero backup describe <nom> dit de quel type il s'agit, et le nombre de PodVolumeBackup (kubectl -n velero get podvolumebackups) vaut zéro pour toutes les horaires.

Le montage en une phrase#

deployNodeAgent: true + defaultVolumesToFsBackup: true : chaque volume de pod est sauvegardé sans aucune annotation par application, et le magasin d'objets est http://minio.minio-cluster.svc.cluster.local:9000, joint uniquement par le réseau de pods.

Pourquoi le File System Backup plutôt que les snapshots CSI#

Snapshot CSI File System Backup (ce qu'on utilise)
Où atterrit la copie dans Longhorn, sur les disques des workers qu'on protège dans MinIO, donc en dehors du volume protégé
Survit à un vagrant destroy ❌ non ✅ oui
Prérequis supplémentaires le contrôleur external-snapshotter + une VolumeSnapshotClassaucun des deux n'est installé par ce lab rien de plus que le DaemonSet node-agent
StorageClass couvertes celles dont le driver CSI gère les snapshots touteslonghorn, longhorn-r1, local-path
Coût instantané (copy-on-write) lit et envoie les octets, dédupliqués par kopia

Un snapshot est un mécanisme de retour arrière, une sauvegarde est un mécanisme de copie ailleurs. Sur un lab dont tout l'intérêt est d'être détruit et reconstruit, seul le second mérite le nom.

ℹ️ Longhorn a aussi sa propre cible de sauvegarde S3, configurable dans son UI. C'est un bon outil, et il ne couvre que les volumes Longhorn — ni les manifestes, ni local-path. Les deux cohabitent sans conflit ; cet addon couvre délibérément tout le cluster à la place.

Fichiers#

Fichier Rôle
velero-up.sh l'install : bucket MinIO + utilisateur restreint, namespace, Secret de credentials, chart, Schedules
values.yaml Valeurs Helm : l'init container du plugin AWS, la BackupStorageLocation, les défauts FSB, le node-agent
schedule.yaml deux Schedulehourly-objects (objets, TTL 48h) et daily-full (objets + données des PV, 02:00, TTL 7 jours)

📋 Prérequis#

Prérequis Pourquoi Vérifier
Un MinIO dans le cluster — ../minio-s3/cluster/ de préférence, ../minio-s3/ accepté le magasin d'objets. velero-up.sh prend minio-cluster d'abord, se rabat sur minio-s3, et VELERO_MINIO_NS=… force les deux kubectl -n minio-cluster get svc minio
Namespace velero en PodSecurity privileged le node-agent monte le dossier de pods du kubelet (hostPath), ce que baseline interdit — posé par velero-up.sh kubectl get ns velero --show-labels
helm, curl, openssl dans le PATH le chart, le téléchargement de mc, la clé d'accès générée helm version
mc (client MinIO) crée le bucket et l'utilisateur restreint — téléchargé automatiquement s'il manque mc --version
CLI velerooptionnelle tout ici est en CRD pures, mais une restauration sans la CLI est pénible velero version --client-only
Un addon de stockage, si vous voulez des données de volume à sauvegarder sans PVC dans le cluster, Velero ne sauvegarde que les objets — ce n'est pas une panne, juste une moitié vide kubectl get pvc -A

ℹ️ Pas d'IP LoadBalancer, pas de Gateway, pas d'enregistrement DNS. Velero parle à minio.<ns>.svc.cluster.local:9000, une ClusterIP : cet addon se comporte à l'identique que les IP LoadBalancer du lab viennent de l'annonceur L2 de Cilium ou de ../metallb/, et il continue de fonctionner quand aucun des deux n'est installé.

⚡ Installation#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> velero       # <distro> = talos | kubeadm

Versions épinglées : chart Velero 12.1.0 (app v1.18.1), plugin velero/velero-plugin-for-aws:v1.14.2.

./velero/velero-up.sh <distro>

Idempotent : helm upgrade --install + kubectl apply, et l'utilisateur MinIO garde sa clé d'accès existante (en régénérer une à chaque passage ferait silencieusement échouer tous les envois en 403).

Variable Défaut Effet
VELERO_VERSION 12.1.0 version du chart
VELERO_AWS_PLUGIN_VERSION v1.14.2 plugin de magasin d'objets — doit correspondre à la mineure de Velero (v1.14.x ↔ v1.18.x)
VELERO_MINIO_NS détecté quel MinIO viser (minio-cluster, puis minio-s3)
VELERO_BUCKET velero nom du bucket — créé s'il manque
VELERO_S3_USER velero utilisateur MinIO, restreint à ce seul bucket
VELERO_NS velero namespace de Velero lui-même
MC_ALIAS labminio l'alias mc temporaire du port-forward. Doit commencer par une lettre — sinon mc refuse le nom (c'est ce qui cassait l'install avec l'ancien _lab), et il évite volontairement lab tout court pour ne jamais écraser votre propre alias

🧬 Talos vs kubeadm#

Aucune divergence à l'installation : les mêmes 4 étapes, le même chart, les mêmes valeurs sur les deux labs. La seule ligne qui casserait sur Talos si on la laissait implicite est portée par une variable de profil, VELERO_POD_VOLUME_PATH.

Talos kubeadm
hostPath du node-agent /var/lib/kubelet/pods — ça marche parce que le dossier racine du kubelet est sous /var, le seul système de fichiers inscriptible (/ et /usr sont en lecture seule) /var/lib/kubelet/pods — le chemin upstream sur un système de fichiers ordinaire, rien de particulier
Label PodSecurity privileged sur velero obligatoire : baseline est imposé sur tout le cluster et interdit les volumes hostPath. Sans le label, le DaemonSet existe et ne crée aucun pod — en silence documente le besoin ; n'impose rien aujourd'hui
Outillage hôte kubectl, helm, curl, openssl (mc téléchargé automatiquement) identique — pas de talosctl, Velero ne touche jamais à la configuration des nodes
Étapes du script 4 4
Ce qui finit dans la sauvegarde identique : les objets, plus tout volume de pod monté identique

Pourquoi une variable pour une valeur identique des deux côtés : ça transforme « le défaut du chart tombe juste sur Talos » en fait vérifié. Une image d'installation qui déplacerait la racine du kubelet (--root-dir) demanderait exactement une ligne de changement, dans lib/profiles/talos.sh, et pas un seul if dans cet addon — la règle que ce dépôt applique à toute divergence (voir ../LISEZ-MOI.md).

🎓 Pas à pas guidé#

Les commandes ci-dessous sont exactement ce que fait velero-up.sh, dans l'ordre. Préparez d'abord votre shell (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig      # ou ../Vagrant-KubeADM/kubeconfig
export MINIO_NS=minio-cluster                      # ou minio-s3

1. Namespace + PodSecurity privileged#

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

⚠️ Sur Talos, oublier ce label est la panne silencieuse classique : kubectl -n velero get ds affiche DESIRED 6 / READY 0 et aucun pod. La raison est dans les événements du DaemonSet, nulle part ailleurs : violates PodSecurity "baseline": hostPath volumes.

2. Le bucket et un utilisateur MinIO restreint#

On ne donne jamais les credentials root de MinIO à un agent cluster-admin.

ROOTPW=$(kubectl -n "$MINIO_NS" get secret minio-creds -o jsonpath='{.data.root-password}' | base64 -d)
kubectl -n "$MINIO_NS" port-forward svc/minio 19010:9000 &
mc alias set labminio http://127.0.0.1:19010 admin "$ROOTPW"

mc mb --ignore-existing labminio/velero
cat > /tmp/velero-policy.json <<'JSON'
{ "Version":"2012-10-17","Statement":[
  {"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::velero","arn:aws:s3:::velero/*"]} ]}
JSON
mc admin policy create labminio velero-rw /tmp/velero-policy.json
SK=$(openssl rand -base64 21 | tr -d '/+=' | head -c 28)
mc admin user add labminio velero "$SK"
mc admin policy attach labminio velero-rw --user velero
kill %1                                  # le port-forward a fait son travail

3. Le Secret de credentials — une clé cloud, un fichier de credentials AWS#

kubectl -n velero create secret generic velero-s3 \
  --from-literal=cloud="$(printf '[default]\naws_access_key_id=velero\naws_secret_access_key=%s\n' "$SK")" \
  --dry-run=client -o yaml | kubectl apply -f -

4. Le chart#

helm repo add vmware-tanzu https://vmware-tanzu.github.io/helm-charts && helm repo update vmware-tanzu
# values.yaml vise minio-cluster par défaut ; pointez le vôtre s'il est ailleurs :
sed "s#minio\.minio-cluster\.svc#minio.${MINIO_NS}.svc#" velero/values.yaml > /tmp/velero-values.yaml
helm upgrade --install velero vmware-tanzu/velero \
  --namespace velero \
  --version 12.1.0 \
  --values /tmp/velero-values.yaml \
  --set nodeAgent.podVolumePath=/var/lib/kubelet/pods \
  --wait --timeout 10m
kubectl -n velero rollout status deploy/velero
kubectl -n velero rollout status ds/node-agent

5. Le verdict de Velero lui-même sur le bucket#

Un helm install qui réussit ne prouve rien sur les credentials. Ceci, si :

kubectl -n velero get backupstoragelocation default
# NAME      PHASE       LAST VALIDATED   AGE   DEFAULT
# default   Available   10s              1m    true

6. Les sauvegardes récurrentes#

kubectl apply -f velero/schedule.yaml
kubectl -n velero get schedules      # hourly-objects + daily-full, avec leurs crons

🔧 Ce que fait le script#

Étape Action
[1/4] namespace velero + les trois labels PodSecurity privileged
[2/4] MinIO : bucket velero, policy velero-rw, utilisateur velero restreint, puis le Secret velero-s3
[3/4] chart Helm avec l'endpoint résolu, puis attente de BackupStorageLocation: Available
[4/4] kubectl apply -f schedule.yaml — les deux Schedule (hourly-objects, daily-full)

Les réglages Helm qui comptent#

Réglage Valeur Pourquoi
initContainers[0] velero-plugin-for-aws:v1.14.2 l'image Velero n'embarque aucun plugin de provider ; sans lui le serveur boucle sur unable to locate ObjectStore plugin for aws
config.s3ForcePathStyle "true" MinIO sert un bucket comme un chemin (minio:9000/velero), pas comme un sous-domaine
config.region us-east-1 MinIO l'ignore, le SDK AWS refuse de signer sans
deployNodeAgent true pas de DaemonSet, pas de données de volume — les objets seuls
defaultVolumesToFsBackup true couvre chaque volume de pod sans annotation par application — et c'est un défaut au niveau du serveur, ce qui est précisément pourquoi hourly-objects doit le repasser explicitement à false
uploaderType kopia dédupliqué et compressé ; restic est l'ancien monde
snapshotsEnabled false pas de VolumeSnapshotLocation : il n'y a pas de contrôleur de snapshot dans ce lab, et une VSL en provider AWS ne produirait que des erreurs
defaultBackupTTL 168h 7 jours — chaque octet atterrit sur le disque partagé des workers

Les objets que Velero crée#

Kind Rôle
BackupStorageLocation le bucket et sa santé (Available / Unavailable)
Schedule une fabrique de Backup — un objet par tick
Backup / Restore une exécution chacun ; le tarball vit dans le bucket, l'objet dans etcd
PodVolumeBackup / PodVolumeRestore un par volume : c'est là qu'apparaissent l'avancement et les échecs du FSB
BackupRepository le dépôt kopia, un par (namespace, storage location)

✅ Vérifier#

kubectl -n velero get backupstoragelocation default        # PHASE=Available
kubectl -n velero get pods                                 # velero + un node-agent PAR NODE
kubectl -n velero get schedules                            # hourly-objects + daily-full

# La vraie preuve : une sauvegarde qui va au bout, avec ses volumes
velero backup create smoke --wait
velero backup describe smoke --details | sed -n '/Phase/p;/Item/p'
kubectl -n velero get podvolumebackups                     # une ligne par volume monté, Completed

Vérifier que les deux cadences diffèrent vraiment — le compte de PodVolumeBackup est le révélateur :

# objets seuls : on attend ZÉRO PodVolumeBackup pour cette sauvegarde
velero backup create objs-only --snapshot-volumes=false --default-volumes-to-fs-backup=false --wait
kubectl -n velero get podvolumebackups -l velero.io/backup-name=objs-only    # « No resources found »

# objets + données : on attend un PodVolumeBackup par volume monté
velero backup create full-now --default-volumes-to-fs-backup --wait
kubectl -n velero get podvolumebackups -l velero.io/backup-name=full-now     # tous Completed

Sans la CLI, la même chose en kubectl seul :

kubectl -n velero create -f - <<'EOF'
apiVersion: velero.io/v1
kind: Backup
metadata: { name: smoke, namespace: velero }
spec: { defaultVolumesToFsBackup: true, ttl: 168h }
EOF
kubectl -n velero get backups.velero.io smoke -o jsonpath='{.status.phase}{"\n"}'    # Completed

Et les objets ont bien atterri dans MinIO :

kubectl -n minio-cluster port-forward svc/minio 19010:9000 &
mc ls -r labminio/velero/backups/smoke/
kill %1

🧪 Scénario — supprimer un namespace avec ses données Longhorn, et le récupérer#

La démo qui justifie l'addon. Elle demande une StorageClass (../longhorn/ ; local-path marche aussi).

# 1. Une application avec un volume, et un octet reconnaissable
kubectl create namespace demo-backup
kubectl apply -n demo-backup -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: data }
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: longhorn
  resources: { requests: { storage: 1Gi } }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: writer }
spec:
  replicas: 1
  selector: { matchLabels: { app: writer } }
  template:
    metadata: { labels: { app: writer } }
    spec:
      containers:
        - name: sh
          image: busybox:1.37
          command: ["sh", "-c", "sleep infinity"]
          volumeMounts: [{ name: data, mountPath: /data }]
      volumes:
        - { name: data, persistentVolumeClaim: { claimName: data } }
EOF
kubectl -n demo-backup rollout status deploy/writer
kubectl -n demo-backup exec deploy/writer -- sh -c 'echo "precieux" > /data/proof.txt'

# 2. On sauvegarde — la définition du PVC ET les octets
velero backup create demo-backup-1 --include-namespaces demo-backup --wait
kubectl -n velero get podvolumebackups           # une ligne Completed pour le volume `data`

# 3. Le sinistre
kubectl delete namespace demo-backup
kubectl get pvc -n demo-backup                   # disparu, volume compris

# 4. La restauration
velero restore create --from-backup demo-backup-1 --wait
kubectl -n demo-backup rollout status deploy/writer
kubectl -n demo-backup exec deploy/writer -- cat /data/proof.txt      # precieux

# 5. Nettoyage
kubectl delete namespace demo-backup

ℹ️ L'étape 4 est celle où le FSB se montre : Velero recrée le PVC, puis le node-agent injecte un init container restore-wait dans le pod, qui bloque le démarrage tant que les données ne sont pas réécrites. Un pod bloqué en Init:0/1 un moment est normal — surveillez kubectl -n velero get podvolumerestores.

🚑 Dépannage#

Symptôme Cause Correction
BackupStorageLocation en Unavailable mauvais credentials, bucket absent, MinIO à terre kubectl -n velero logs deploy/velero | tail -30 ; relancer velero-up.sh (il répare le bucket, la policy et le Secret)
Le serveur logue unable to locate ObjectStore plugin for aws l'init container n'a jamais tourné (valeurs écrasées, initContainers vidé) kubectl -n velero get deploy velero -o jsonpath='{.spec.template.spec.initContainers[*].image}'
node-agent en DESIRED n / READY 0, aucun pod le namespace n'est pas labellisé privileged (Talos) kubectl label ns velero pod-security.kubernetes.io/enforce=privileged --overwrite
Sauvegarde en PartiallyFailed, un PodVolumeBackup en Failed un volume que l'agent ne peut pas lire (hostPath, volume non monté) kubectl -n velero get podvolumebackups -o wide, puis les logs du pod concerné
Tout tombe en 403 après une réinstallation de MinIO MinIO a perdu l'utilisateur velero, le Secret porte encore l'ancienne clé supprimer le Secret et relancer : kubectl -n velero delete secret velero-s3 && ./velero/velero-up.sh <distro>
L'envoi échoue sur une erreur de checksum/signature certaines implémentations S3 rejettent le checksum par défaut du SDK ajouter checksumAlgorithm: "" au bloc config: de values.yaml (README du plugin)
velero: command not found la CLI est optionnelle et pas installée utiliser les formes kubectl ci-dessus, ou l'installer (voir Références)

⚠️ Pièges#

  • Le FSB ne sauvegarde qu'un volume monté par un pod. Un PVC lié à rien — ou lié à un pod scalé à zéro — voit sa définition sauvegardée et son contenu ignoré, sans erreur. Remontez l'application avant de sauvegarder des données qui comptent, et regardez kubectl -n velero get podvolumebackups plutôt que de faire confiance au seul Phase: Completed du Backup.
  • Les volumes hostPath ne sont jamais sauvegardés par le FSB (par conception). Dans ce lab ça ne concerne guère que les DaemonSets système, ce qui va bien — mais n'attendez pas que les dossiers node de local-path reviennent autrement que par leur PVC.
  • defaultVolumesToFsBackup: true embarque aussi les emptyDir. Le WAL de Prometheus et autres volumes de travail seront envoyés. Excluez volume par volume :
    kubectl -n <ns> annotate pod <pod> backup.velero.io/backup-volumes-excludes=cache,tmp
    
  • Ne comptez pas sur Velero pour restaurer MinIO lui-même. La sauvegarde du namespace minio-cluster vit dans minio-cluster : si MinIO n'est plus là, la sauvegarde non plus. La résilience de MinIO, c'est son erasure coding, pas cet addon.
  • Un Service LoadBalancer restauré ne reprend pas forcément la même IP. L'annonceur (Cilium L2 ou MetalLB) réalloue depuis le pool. Si un enregistrement DNS pointe sur .200, vérifiez où le Gateway est retombé après une restauration complète.
  • Une restauration de tout le cluster inclut le namespace velero sauf si on l'exclut. Dans un cluster neuf, utilisez velero restore create --from-backup <b> --exclude-namespaces velero, sinon la restauration se bat contre le Velero qui l'exécute.
  • Le cron du Schedule est lu dans le fuseau du serveur (UTC dans ce lab), pas celui de votre poste. 0 2 * * *, c'est 13:00 à Nouméa, et hourly-objects part à l'heure UTC pile.
  • Restaurer la sauvegarde la plus récente n'est pas toujours ce qu'on veut. Avec deux schedules, la dernière sauvegarde est presque toujours une hourly-objects, qui ne porte aucune donnée de volume. Choisissez délibérément : kubectl -n velero get backups.velero.io --sort-by=.metadata.creationTimestamp puis restaurez depuis la dernière daily-full-* s'il faut récupérer le contenu des PVC.
  • kubectl get backups est AMBIGU dans ce lab et répond en silence à propos de Longhorn. Trois CRD installées revendiquent le kind Backuplonghorn.io, velero.io et postgresql.cnpg.io — et kubectl résout le pluriel nu vers celle de Longhorn. Donc kubectl -n velero get backups affiche No resources found in velero namespace. alors que les sauvegardes Velero sont juste là : un faux négatif parfaitement convaincant. Qualifiez toujours : kubectl -n velero get backups.velero.io. podvolumebackups et schedules, eux, ne sont pas ambigus — c'est ce qui rend le piège si facile.
  • velero backup logs / describe --details échouent depuis votre poste. Ils demandent à l'API server une URL présignée, forgée contre l'endpoint de la BackupStorageLocation — http://minio.<ns>.svc.cluster.local:9000, un nom DNS interne au cluster que votre hôte ne peut pas résoudre (dial tcp: lookup … no such host). La sauvegarde elle-même est saine. Pour lire les logs ou la liste des erreurs, passez par le bucket :
    kubectl -n <ns-minio> port-forward svc/minio 19010:9000 &
    mc alias set labminio http://127.0.0.1:19010 admin "$ROOTPW"
    mc cp labminio/velero/backups/<backup>/<backup>-results.gz - | gunzip | python3 -m json.tool
    
  • velero backup delete supprime aussi les objets du bucket ; kubectl delete backup ne supprime que l'objet Kubernetes et laisse le tarball orphelin dans MinIO.
  • Les TTL sont de 48 h (horaire) et 7 jours (quotidien). Un lab éteint quinze jours revient avec un bucket vide — le GC tourne sur l'horloge de Velero, pas sur le besoin qu'on avait de cette sauvegarde.
  • 24 ticks horaires par jour, ce sont 24 objets Backup de plus par jour. À 48h de TTL ça se stabilise vers ~50 objets ; kubectl -n velero get backups.velero.io devient bruyant bien avant de devenir coûteux. Filtrez avec -l velero.io/schedule-name=daily-full quand seules les sauvegardes restaurables-avec-données vous intéressent.

🧹 Désinstallation#

kubectl delete -f velero/schedule.yaml
helm uninstall velero -n velero
kubectl delete namespace velero          # emporte les CR ; les CRD survivent
kubectl get crd | sed -n '/velero.io/p' | awk '{print $1}' | xargs -r kubectl delete crd
# Le bucket n'est PAS supprimé : mc rb --force labminio/velero

📚 Références#

🐘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 longhorn/longhorn-r1-storageclass.yaml. With fewer than 3 workers, lower instances in cluster-demo.yaml (otherwise one pod stays Pending).

⚡ Install#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> cnpg     # <distro> = talos | kubeadm
kubectl apply -f longhorn/longhorn-r1-storageclass.yaml   # if not already done
./cloudnative-pg/cloudnative-pg-up.sh <distro>

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

🧬 Talos vs kubeadm#

No distribution-specific behaviour for this component: same charts, same manifests, same values on both labs. The distribution argument only drives two things here: the default domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env / kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).

ℹ️ The prerequisite is the longhorn-r1 StorageClass: Longhorn is where the distribution differences live (see ../longhorn/).

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. Check the storage prerequisite#

kubectl get sc longhorn-r1     # otherwise: ./install.sh <distro> longhorn

2. The operator#

helm repo add cnpg https://cloudnative-pg.github.io/charts && helm repo update cnpg
helm upgrade --install cnpg cnpg/cloudnative-pg -n cnpg-system --create-namespace \
  --version 0.29.0 \
  --values cloudnative-pg/values.yaml
kubectl -n cnpg-system rollout status deploy/cnpg-cloudnative-pg --timeout=180s
kubectl get crd | grep postgresql.cnpg.io

3. The demo PostgreSQL cluster (3 instances, 1Gi RWO each)#

kubectl apply -f cloudnative-pg/cluster-demo.yaml
kubectl -n cnpg-demo wait --for=condition=Ready cluster/pg-demo --timeout=600s
kubectl -n cnpg-demo get cluster pg-demo
kubectl -n cnpg-demo get pods -l cnpg.io/cluster=pg-demo -o wide

4. Connect (the rw / ro / r Services)#

kubectl -n cnpg-demo get svc | grep pg-demo         # pg-demo-rw, -ro, -r
kubectl -n cnpg-demo get secret pg-demo-app -o jsonpath='{.data.uri}' | base64 -d; echo
kubectl -n cnpg-demo exec -it pg-demo-1 -c postgres -- psql -c 'select version();'

5. Watch an automatic failover (the heart of the demo)#

kubectl -n cnpg-demo get pods -l cnpg.io/instanceRole=primary      # who is primary?
kubectl -n cnpg-demo delete pod <the-primary>
kubectl -n cnpg-demo get cluster pg-demo -w                        # a replica gets promoted

6. S3 backups + PITR (dedicated scripts)#

./install.sh <distro> minio-cluster                    # the S3 target
./cloudnative-pg/pg-backup-up.sh <distro>              # WAL + base backup to S3
./cloudnative-pg/pg-app-backup-cnpg-up.sh <distro>     # application-level backup

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

./cloudnative-pg/pg-backup-up.sh <distro>     # 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
./cloudnative-pg/pg-app-backup-cnpg-up.sh <distro>

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

🐘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 longhorn/longhorn-r1-storageclass.yaml. Avec moins de 3 workers, réduire instances dans cluster-demo.yaml (sinon un pod reste Pending).

⚡ Installation#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> cnpg     # <distro> = talos | kubeadm
kubectl apply -f longhorn/longhorn-r1-storageclass.yaml   # si pas déjà fait
./cloudnative-pg/cloudnative-pg-up.sh <distro>

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

🧬 Talos vs kubeadm#

Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes, mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou ../Vagrant-KubeADM/).

ℹ️ Le prérequis est la StorageClass longhorn-r1 : c'est Longhorn qui porte les différences de distribution (cf. ../longhorn/).

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Vérifier le prérequis stockage#

kubectl get sc longhorn-r1     # sinon : ./install.sh <distro> longhorn

2. L'opérateur#

helm repo add cnpg https://cloudnative-pg.github.io/charts && helm repo update cnpg
helm upgrade --install cnpg cnpg/cloudnative-pg -n cnpg-system --create-namespace \
  --version 0.29.0 \
  --values cloudnative-pg/values.yaml
kubectl -n cnpg-system rollout status deploy/cnpg-cloudnative-pg --timeout=180s
kubectl get crd | grep postgresql.cnpg.io

3. Le cluster PostgreSQL de démo (3 instances, 1Gi RWO chacune)#

kubectl apply -f cloudnative-pg/cluster-demo.yaml
kubectl -n cnpg-demo wait --for=condition=Ready cluster/pg-demo --timeout=600s
kubectl -n cnpg-demo get cluster pg-demo
kubectl -n cnpg-demo get pods -l cnpg.io/cluster=pg-demo -o wide

4. Se connecter (les Services rw / ro / r)#

kubectl -n cnpg-demo get svc | grep pg-demo         # pg-demo-rw, -ro, -r
kubectl -n cnpg-demo get secret pg-demo-app -o jsonpath='{.data.uri}' | base64 -d; echo
kubectl -n cnpg-demo exec -it pg-demo-1 -c postgres -- psql -c 'select version();'

5. Observer une bascule automatique (le cœur de la démo)#

kubectl -n cnpg-demo get pods -l cnpg.io/instanceRole=primary      # qui est primaire ?
kubectl -n cnpg-demo delete pod <le-primaire>
kubectl -n cnpg-demo get cluster pg-demo -w                        # un réplica est promu

6. Sauvegardes S3 + PITR (scripts dédiés)#

./install.sh <distro> minio-cluster                    # la cible S3
./cloudnative-pg/pg-backup-up.sh <distro>              # sauvegarde WAL + base vers S3
./cloudnative-pg/pg-app-backup-cnpg-up.sh <distro>     # sauvegarde applicative

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

./cloudnative-pg/pg-backup-up.sh <distro>     # 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
./cloudnative-pg/pg-app-backup-cnpg-up.sh <distro>

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

🛂keycloak/ — Keycloak through its operator, exposed with the Gateway API

An identity provider the cluster can rebuild from a file. Keycloak is deployed by its operator: a thirty-line Keycloak CR replaces the StatefulSet, the Service and the whole server configuration, and a KeycloakRealmImport CR makes the lab realm reproducible. The admin console and the OIDC endpoints are published at keycloak.lab.example.io behind the same main-gateway as the rest of the lab, on the *.lab.example.io wildcard that is already issued — nothing new on the certificate side.

🌐 lab.example.io is the repo's NEUTRAL domain (public): keycloak-up.sh swaps it for LAB_DOMAIN (lab.env) in the Keycloak CR, the realm and the HTTPRoute. See ../README.md.

🎯 Purpose#

  • Give the lab a real identity provider: realms, users, groups, OIDC and SAML clients.
  • Show a CRD-driven deployment: you declare what you want (Keycloak, KeycloakRealmImport), the operator derives the how and keeps reconciling it.
  • Serve as the upstream IdP for anything that authenticates afterwards — starting with kubectl itself, through the Dex add-on.

ℹ️ Standalone add-on: Keycloak is not installed by ../platform-up.sh (which only lays down the CNI + Envoy Gateway + metrics-server + the wildcard TLS). It installs on demand, like ../longhorn/, ../vault-cluster/, ../argocd/

The setup in one sentence#

Envoy terminates TLS, Keycloak speaks plaintext — and is told so. http.httpEnabled: true makes the pod listen on plain 8080; proxy.headers: xforwarded makes Keycloak trust the X-Forwarded-* headers Envoy sets; hostname.hostname pins the public URL. Miss the second one and Keycloak builds its redirects from its internal name — the browser loops and OAuth breaks. Miss the third and the issuer advertised in the discovery document does not match the URL clients actually call, so every token is rejected.

Why the operator and not a chart#

A Helm chart hands you a StatefulSet and leaves you with the keystore, the proxy options, the schema migration on every upgrade and the Infinispan cache. The operator takes a Keycloak object and derives all of it. More importantly it gives you the thing that matters in a lab: a declared realm you can version, therefore reproduce — and, for downstream components, an OIDC client declared as a CR instead of a kcadm.sh script.

📋 Prerequisites#

Prerequisite Why Verify
main-gateway with the https:443 listener (../envoy-gateway/) carries the console and the OIDC endpoints over HTTPS kubectl get gateway -n envoy-gateway-system
Wildcard cert wildcard-lab-example-io-tls READY=True (../cert-manager/ or ../self-signed/) otherwise TLS is untrusted, and OIDC clients refuse the issuer kubectl -n envoy-gateway-system get certificate
StorageClass longhorn-r1 (../longhorn/) the PostgreSQL PVC kubectl get sc longhorn-r1
CloudNativePG operator (../cloudnative-pg/) reconciles the keycloak-db cluster kubectl get crd clusters.postgresql.cnpg.io
DNS keycloak.lab.example.io → 192.168.56.200 in DNS-only hostname of the HTTPRoute curl --resolve otherwise (see ✅)
openssl on the host generates the demo user's password openssl version
Nothing on the node side no hostPath, no hostNetwork, no privilege kubectl -n keycloak get pods

⚡ Install#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> cnpg keycloak     # <distro> = talos | kubeadm
./keycloak/keycloak-up.sh <distro>

Keycloak 26.7.0 (operator and server: the operator manifests carry the server image), pinned in the script via KEYCLOAK_VERSION (can be overridden). Idempotent (kubectl apply everywhere; the demo password is generated only if its Secret is missing).

Manual equivalent
V=26.7.0
B=https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/$V/kubernetes
kubectl create namespace keycloak
kubectl apply -f keycloak/01-postgres.yaml
kubectl -n keycloak wait --for=condition=Ready cluster/keycloak-db --timeout=300s
# --server-side is mandatory: the keycloaks CRD is larger than the 262 144-byte cap on the
# last-applied-configuration annotation that a client-side apply would write.
for c in keycloaks keycloakrealmimports keycloakoidcclients keycloaksamlclients; do
  kubectl apply --server-side -f "$B/$c.k8s.keycloak.org-v1.yml"
done
kubectl apply -n keycloak -f "$B/kubernetes.yml"
kubectl -n keycloak rollout status deploy/keycloak-operator
kubectl apply -f keycloak/02-keycloak.yaml            # after substituting the domain
kubectl -n keycloak create secret generic keycloak-demo-user \
  --from-literal=password="$(openssl rand -base64 18)"
kubectl apply -f keycloak/03-realm-lab.yaml
kubectl apply -f keycloak/httproute.yaml

🧬 Talos vs kubeadm#

No distribution-specific behaviour for this component. Same manifests, same CRs, same versions on both labs — and, unusually for this repository, not even a privileged namespace label: the Keycloak and PostgreSQL pods use no hostPath, no hostNetwork, no privilege, so they already satisfy the baseline PodSecurity level Talos enforces cluster-wide.

The distribution argument only drives two things here: the default domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env / kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).

ℹ️ The one thing that does differ per lab is the trust in the certificate, and it only bites downstream. With SELF_SIGNED=true (the repo default) the issuer https://keycloak.<LAB_DOMAIN> is signed by the lab's local CA: a browser warns, and any OIDC client — including the Kubernetes apiserver — must be handed that CA explicitly. See ../self-signed/README.md.

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. The namespace and the database#

Keycloak stores everything in the database — realms, clients, users, offline sessions. Without one it starts from scratch on every pod restart.

kubectl create namespace keycloak
kubectl apply -f keycloak/01-postgres.yaml
kubectl -n keycloak wait --for=condition=Ready cluster/keycloak-db --timeout=300s
kubectl -n keycloak get secret keycloak-db-app -o jsonpath='{.data.username}' | base64 -d; echo

The keycloak-db-app Secret is produced by CloudNativePG, not by us: that is the whole point of going through the operator rather than writing a postgres:17 Deployment by hand.

2. The Keycloak operator#

V=26.7.0
B=https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/$V/kubernetes
for c in keycloaks keycloakrealmimports keycloakoidcclients keycloaksamlclients; do
  kubectl apply --server-side -f "$B/$c.k8s.keycloak.org-v1.yml"
done
kubectl apply -n keycloak -f "$B/kubernetes.yml"
kubectl -n keycloak rollout status deploy/keycloak-operator --timeout=300s
kubectl api-resources --api-group=k8s.keycloak.org

Two details worth knowing: the CRDs must go in with --server-side (the keycloaks one is larger than the 262 144-byte annotation cap of a client-side apply), and the operator must live in the keycloak namespace (its ClusterRoleBinding names that namespace in its subject).

3. The Keycloak CR — the whole deployment#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" keycloak/02-keycloak.yaml | kubectl apply -f -
kubectl -n keycloak get statefulset,svc          # created by the operator, not by you
kubectl -n keycloak wait --for=condition=Ready keycloak/keycloak --timeout=600s

First start takes a couple of minutes: Keycloak creates its schema. Watch it with kubectl -n keycloak logs -f keycloak-0.

4. The lab realm, with no password in git#

kubectl -n keycloak create secret generic keycloak-demo-user \
  --from-literal=password="$(openssl rand -base64 18)"
sed "s/lab\.example\.io/${LAB_DOMAIN}/g" keycloak/03-realm-lab.yaml | kubectl apply -f -
kubectl -n keycloak wait --for=condition=Done keycloakrealmimport/lab --timeout=300s
kubectl -n keycloak get jobs                     # the import job, run once

spec.placeholders exposes the Secret key as an environment variable of the import job, and Keycloak substitutes ${DEMO_PASSWORD} while importing. The repository is public: no credential is ever versioned.

5. The route, and the discovery document#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" keycloak/httproute.yaml | kubectl apply -f -
curl -sk "https://keycloak.${LAB_DOMAIN}/realms/lab/.well-known/openid-configuration" | jq .issuer

The issuer must read exactly https://keycloak.<LAB_DOMAIN>/realms/lab. If it shows an internal name instead, proxy.headers or hostname.hostname is wrong — see 🚑 below.

🔧 What the script does#

  1. creates the keycloak namespace and the CloudNativePG keycloak-db cluster, then waits for it to be Ready;
  2. applies the four CRDs (--server-side) and the operator, in the keycloak namespace;
  3. applies the Keycloak CR with the domain substituted, and waits for condition=Ready;
  4. generates the demo password only if its Secret is missing, then applies the realm import;
  5. applies the HTTPRoute and prints the URLs and the commands that read the credentials.

Files#

File Purpose
01-postgres.yaml CloudNativePG Cluster keycloak-db — 1 instance, 2Gi on longhorn-r1, database and owner keycloak
02-keycloak.yaml the Keycloak CR: database, httpEnabled, proxy.headers: xforwarded, public hostname, operator Ingress disabled
03-realm-lab.yaml KeycloakRealmImport: realm lab, groups k8s-admins / k8s-viewers, groups client scope, the demo and viewer users whose passwords come from Secrets
httproute.yaml HTTPS HTTPRoute keycloak.lab.example.iokeycloak-service:8080, sectionName: https
keycloak-up.sh the all-in-one install (idempotent)

✅ Verify#

kubectl -n keycloak get keycloak,keycloakrealmimport      # Ready=True, Done=True
kubectl -n keycloak get pods                              # keycloak-0, keycloak-db-1, operator
kubectl -n keycloak get httproute keycloak                # Accepted + ResolvedRefs = True
# end-to-end test, TLS served by Envoy:
curl -sS -o /dev/null -w '%{http_code} verify=%{ssl_verify_result}\n' \
  --resolve keycloak.lab.example.io:443:192.168.56.200 \
  https://keycloak.lab.example.io/realms/lab            # expected: 200 verify=0

--resolve bypasses DNS: handy for testing before you create the record. verify=0 only holds with a publicly trusted certificate; with SELF_SIGNED=true, add -k or pass the lab CA with --cacert.

🌐 Access#

What Value
Admin console https://keycloak.lab.example.io/admin/
Bootstrap admin kubectl -n keycloak get secret keycloak-initial-admin -o jsonpath='{.data.username}' | base64 -d ; echo
Its password kubectl -n keycloak get secret keycloak-initial-admin -o jsonpath='{.data.password}' | base64 -d ; echo
Realm labhttps://keycloak.lab.example.io/realms/lab
Discovery https://keycloak.lab.example.io/realms/lab/.well-known/openid-configuration
Demo user demo, member of k8s-admins
Its password kubectl -n keycloak get secret keycloak-demo-user -o jsonpath='{.data.password}' | base64 -d ; echo
View-only user viewer, member of k8s-viewers
Its password kubectl -n keycloak get secret keycloak-viewer-user -o jsonpath='{.data.password}' | base64 -d ; echo

💡 Create your own admin in the master realm, then delete the bootstrap Secret: kubectl -n keycloak delete secret keycloak-initial-admin.

🧪 Scenario — the realm survives its cluster#

The point of a declared realm is that it comes back. Destroy it and let the operator rebuild it:

# 1. Note what exists
kubectl -n keycloak get keycloakrealmimport lab -o jsonpath='{.status.conditions}' | jq

# 2. Blow the whole deployment away — CR only, the database survives
kubectl -n keycloak delete keycloak keycloak
kubectl -n keycloak get pods -w            # the operator rebuilds the StatefulSet

# 3. Re-apply: same URL, same realm, same users — the state was in PostgreSQL all along
kubectl apply -f keycloak/02-keycloak.yaml
curl -sk https://keycloak.lab.example.io/realms/lab | jq .realm

Now the other direction — a genuinely empty database:

kubectl -n keycloak delete keycloakrealmimport lab
kubectl -n keycloak delete cluster keycloak-db          # ⚠️ destroys the data
./keycloak/keycloak-up.sh <distro>                      # everything comes back from the files

Second run, the demo password is the same: the script does not regenerate a Secret that already exists.

🚑 Troubleshooting#

  • issuer shows an internal name, or the browser loops on loginproxy.headers is not xforwarded, or hostname.hostname does not match the HTTPRoute hostname: kubectl -n keycloak get keycloak keycloak -o jsonpath='{.spec.hostname}{"\n"}{.spec.proxy}'.
  • metadata.annotations: Too long while applying a CRD → you used a client-side apply. The keycloaks CRD is over 500 KiB; use kubectl apply --server-side.
  • The operator starts but reconciles nothing → it is not in the keycloak namespace, so its ClusterRoleBinding (which names that namespace explicitly) grants it nothing: kubectl -n keycloak logs deploy/keycloak-operator.
  • keycloak-0 in CrashLoopBackOff at first start → almost always the database. Check kubectl -n keycloak get cluster keycloak-db and the credentials in keycloak-db-app. An OOMKill during the Liquibase migration looks the same: raise spec.resources.limits.memory.
  • The realm import job never finisheskubectl -n keycloak get keycloakrealmimport lab -o yaml then the job's logs. A missing placeholder Secret fails the import with an obscure message.
  • You edited 03-realm-lab.yaml and nothing changed → expected. The import does not overwrite an existing realm. Delete the CR and the realm, then re-apply.
  • 404 on the routekubectl -n keycloak describe httproute keycloak; sectionName: https must exist on main-gateway and the hostname must match the wildcard.

⚠️ Pitfalls#

  • proxy.headers: xforwarded is only safe behind a proxy you control. It makes Keycloak trust X-Forwarded-Host. If the Service were reachable directly, anyone could poison the links in password-reset emails. Keep it a ClusterIP behind the Gateway.
  • keycloak-initial-admin is a full admin credential in plaintext for as long as you keep it: readable by anything holding get secrets in the namespace.
  • The IdP is published on the VIP: every authorized Tailscale peer can reach the login page. Brute-force protection is on in the realm, but a real password is still mandatory.
  • The realm import is one-shot. It documents the initial state, not the current one. Any change made in the console drifts silently from this file — this is not GitOps.
  • Destroying the keycloak-db cluster destroys every identity. The Keycloak CR holds no state; PostgreSQL holds all of it, and this lab takes no backup of it (see ../cloudnative-pg/ for S3 backups + PITR).
  • Stacking this component on control planes that are too tight on RAM ends up starving etcd: Keycloak asks for 768 MiB and peaks higher on first start. lab.env (WK_MEM) is the knob.

📚 References#

🛂keycloak/ — Keycloak par son opérateur, exposé via la Gateway API

Un fournisseur d'identité que le cluster sait reconstruire depuis un fichier. Keycloak est déployé par son opérateur : un CR Keycloak de trente lignes remplace le StatefulSet, le Service et toute la configuration serveur, et un CR KeycloakRealmImport rend le realm lab reproductible. La console d'admin et les endpoints OIDC sont publiés sous keycloak.lab.example.io derrière le même main-gateway que le reste du lab, avec le wildcard *.lab.example.io déjà émis — rien de neuf côté certificat.

🌐 lab.example.io est le domaine NEUTRE du dépôt (public) : keycloak-up.sh le remplace par LAB_DOMAIN (lab.env) dans le CR Keycloak, le realm et l'HTTPRoute. Cf. ../LISEZ-MOI.md.

🎯 À quoi ça sert#

  • Donner au lab un vrai fournisseur d'identité : realms, utilisateurs, groupes, clients OIDC et SAML.
  • Montrer un déploiement piloté par CRD : on déclare ce qu'on veut (Keycloak, KeycloakRealmImport), l'opérateur en dérive le comment et le reconcilie en continu.
  • Servir d'IdP amont à tout ce qui s'authentifie ensuite — à commencer par kubectl lui-même, via l'addon Dex.

ℹ️ Addon à part : Keycloak n'est pas installé par ../platform-up.sh (qui ne pose que le CNI + Envoy Gateway + metrics-server + le wildcard TLS). Il s'installe à la demande, comme ../longhorn/, ../vault-cluster/, ../argocd/

Le montage en une phrase#

Envoy termine le TLS, Keycloak parle en clair — et on le lui dit. http.httpEnabled: true fait écouter le pod en clair sur 8080 ; proxy.headers: xforwarded fait confiance aux en-têtes X-Forwarded-* posés par Envoy ; hostname.hostname fige l'URL publique. Sans le deuxième, Keycloak fabrique ses redirections depuis son nom interne — le navigateur boucle et OAuth casse. Sans le troisième, l'issuer annoncé dans le document de découverte ne correspond pas à l'URL réellement appelée, et tous les jetons sont rejetés.

Pourquoi l'opérateur et pas un chart#

Un chart Helm vous donne un StatefulSet et vous laisse avec : la génération du keystore, les options de proxy, la migration de schéma à chaque montée de version, le cache Infinispan. L'opérateur, lui, prend un objet Keycloak et en dérive tout. Surtout, il apporte ce qui compte dans un lab : un realm déclaré, donc versionnable, donc reproductible — et, pour les composants d'aval, un client OIDC déclaré par un CR au lieu d'un script kcadm.sh.

📋 Prérequis#

Prérequis Pourquoi Vérifier
main-gateway avec l'écouteur https:443 (../envoy-gateway/) porte la console et les endpoints OIDC en HTTPS kubectl get gateway -n envoy-gateway-system
Cert wildcard wildcard-lab-example-io-tls READY=True (../cert-manager/ ou ../self-signed/) sinon TLS non trusté, et les clients OIDC refusent l'issuer kubectl -n envoy-gateway-system get certificate
StorageClass longhorn-r1 (../longhorn/) le PVC de PostgreSQL kubectl get sc longhorn-r1
Opérateur CloudNativePG (../cloudnative-pg/) reconcilie le cluster keycloak-db kubectl get crd clusters.postgresql.cnpg.io
DNS keycloak.lab.example.io → 192.168.56.200 en DNS-only hostname de l'HTTPRoute curl --resolve sinon (cf. ✅)
openssl sur l'hôte génère le mot de passe de l'utilisateur de démo openssl version
Rien côté nodes ni hostPath, ni hostNetwork, ni privilège kubectl -n keycloak get pods

⚡ Installation#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> cnpg keycloak     # <distro> = talos | kubeadm
./keycloak/keycloak-up.sh <distro>

Keycloak 26.7.0 (opérateur et serveur : les manifestes de l'opérateur portent l'image du serveur), épinglé dans le script via KEYCLOAK_VERSION (surchargeable). Idempotent (kubectl apply partout ; le mot de passe de démo n'est généré que si son Secret manque).

Équivalent manuel
V=26.7.0
B=https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/$V/kubernetes
kubectl create namespace keycloak
kubectl apply -f keycloak/01-postgres.yaml
kubectl -n keycloak wait --for=condition=Ready cluster/keycloak-db --timeout=300s
# --server-side est obligatoire : la CRD keycloaks dépasse les 262 144 octets que peut
# porter l'annotation last-applied-configuration écrite par un apply côté client.
for c in keycloaks keycloakrealmimports keycloakoidcclients keycloaksamlclients; do
  kubectl apply --server-side -f "$B/$c.k8s.keycloak.org-v1.yml"
done
kubectl apply -n keycloak -f "$B/kubernetes.yml"
kubectl -n keycloak rollout status deploy/keycloak-operator
kubectl apply -f keycloak/02-keycloak.yaml            # après substitution du domaine
kubectl -n keycloak create secret generic keycloak-demo-user \
  --from-literal=password="$(openssl rand -base64 18)"
kubectl apply -f keycloak/03-realm-lab.yaml
kubectl apply -f keycloak/httproute.yaml

🧬 Talos vs kubeadm#

Aucune spécificité de distribution pour ce composant. Mêmes manifestes, mêmes CR, mêmes versions sur les deux labs — et, fait rare dans ce dépôt, pas même une étiquette de namespace privileged : les pods Keycloak et PostgreSQL n'utilisent ni hostPath, ni hostNetwork, ni privilège, ils satisfont donc déjà le niveau PodSecurity baseline que Talos applique au niveau cluster.

La distribution passée en argument ne sert ici qu'à deux choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou ../Vagrant-KubeADM/).

ℹ️ La seule chose qui diffère vraiment d'un lab à l'autre est la confiance dans le certificat, et ça ne mord qu'en aval. Avec SELF_SIGNED=true (le défaut du dépôt), l'issuer https://keycloak.<LAB_DOMAIN> est signé par l'AC locale du lab : le navigateur avertit, et tout client OIDC — apiserver Kubernetes compris — doit recevoir cette AC explicitement. Cf. ../self-signed/LISEZ-MOI.md.

🎓 Pas à pas guidé#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord ton shell (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou le tien (cf. lab.env du lab)

1. Le namespace et la base#

Keycloak stocke tout en base — realms, clients, utilisateurs, sessions offline. Sans base, il repart de zéro à chaque redémarrage de pod.

kubectl create namespace keycloak
kubectl apply -f keycloak/01-postgres.yaml
kubectl -n keycloak wait --for=condition=Ready cluster/keycloak-db --timeout=300s
kubectl -n keycloak get secret keycloak-db-app -o jsonpath='{.data.username}' | base64 -d; echo

Le Secret keycloak-db-app est produit par CloudNativePG, pas par nous : c'est tout l'intérêt de passer par l'opérateur plutôt que d'écrire un Deployment postgres:17 à la main.

2. L'opérateur Keycloak#

V=26.7.0
B=https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/$V/kubernetes
for c in keycloaks keycloakrealmimports keycloakoidcclients keycloaksamlclients; do
  kubectl apply --server-side -f "$B/$c.k8s.keycloak.org-v1.yml"
done
kubectl apply -n keycloak -f "$B/kubernetes.yml"
kubectl -n keycloak rollout status deploy/keycloak-operator --timeout=300s
kubectl api-resources --api-group=k8s.keycloak.org

Deux détails qui comptent : les CRD passent en --server-side (celle de keycloaks dépasse le plafond de 262 144 octets de l'annotation d'un apply côté client), et l'opérateur doit vivre dans le namespace keycloak (son ClusterRoleBinding y désigne son ServiceAccount en dur).

3. Le CR Keycloak — le déploiement entier#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" keycloak/02-keycloak.yaml | kubectl apply -f -
kubectl -n keycloak get statefulset,svc          # créés par l'opérateur, pas par toi
kubectl -n keycloak wait --for=condition=Ready keycloak/keycloak --timeout=600s

Le premier démarrage prend deux bonnes minutes : Keycloak crée son schéma. À suivre avec kubectl -n keycloak logs -f keycloak-0.

4. Le realm lab, sans un seul mot de passe dans git#

kubectl -n keycloak create secret generic keycloak-demo-user \
  --from-literal=password="$(openssl rand -base64 18)"
sed "s/lab\.example\.io/${LAB_DOMAIN}/g" keycloak/03-realm-lab.yaml | kubectl apply -f -
kubectl -n keycloak wait --for=condition=Done keycloakrealmimport/lab --timeout=300s
kubectl -n keycloak get jobs                     # le Job d'import, exécuté une fois

spec.placeholders expose la clé du Secret comme variable d'environnement du Job d'import, et Keycloak substitue ${DEMO_PASSWORD} pendant l'import. Le dépôt est public : aucun identifiant n'est jamais versionné.

5. La route, et le document de découverte#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" keycloak/httproute.yaml | kubectl apply -f -
curl -sk "https://keycloak.${LAB_DOMAIN}/realms/lab/.well-known/openid-configuration" | jq .issuer

L'issuer doit valoir exactement https://keycloak.<LAB_DOMAIN>/realms/lab. S'il affiche un nom interne, c'est proxy.headers ou hostname.hostname qui est faux — cf. 🚑 plus bas.

🔧 Ce que fait le script#

  1. crée le namespace keycloak et le cluster CloudNativePG keycloak-db, puis attend qu'il soit Ready ;
  2. applique les quatre CRD (--server-side) et l'opérateur, dans le namespace keycloak ;
  3. applique le CR Keycloak avec le domaine substitué et attend condition=Ready ;
  4. génère le mot de passe de démo uniquement si son Secret manque, puis applique l'import du realm ;
  5. applique l'HTTPRoute et affiche les URL ainsi que les commandes qui lisent les identifiants.

Fichiers#

Fichier Rôle
01-postgres.yaml Cluster CloudNativePG keycloak-db — 1 instance, 2Gi sur longhorn-r1, base et propriétaire keycloak
02-keycloak.yaml le CR Keycloak : base, httpEnabled, proxy.headers: xforwarded, hostname public, Ingress de l'opérateur désactivé
03-realm-lab.yaml KeycloakRealmImport : realm lab, groupes k8s-admins / k8s-viewers, scope groups, utilisateurs demo et viewer dont les mots de passe viennent de Secrets
httproute.yaml HTTPRoute HTTPS keycloak.lab.example.iokeycloak-service:8080, sectionName: https
keycloak-up.sh l'installation tout-en-un (idempotente)

✅ Vérifier#

kubectl -n keycloak get keycloak,keycloakrealmimport      # Ready=True, Done=True
kubectl -n keycloak get pods                              # keycloak-0, keycloak-db-1, opérateur
kubectl -n keycloak get httproute keycloak                # Accepted + ResolvedRefs = True
# test de bout en bout, TLS servi par Envoy :
curl -sS -o /dev/null -w '%{http_code} verify=%{ssl_verify_result}\n' \
  --resolve keycloak.lab.example.io:443:192.168.56.200 \
  https://keycloak.lab.example.io/realms/lab            # attendu : 200 verify=0

--resolve court-circuite le DNS : pratique pour tester avant de créer l'enregistrement. verify=0 ne tient qu'avec un certificat publiquement trusté ; avec SELF_SIGNED=true, ajoute -k ou passe l'AC du lab avec --cacert.

🌐 Accès#

Quoi Valeur
Console d'admin https://keycloak.lab.example.io/admin/
Admin de bootstrap kubectl -n keycloak get secret keycloak-initial-admin -o jsonpath='{.data.username}' | base64 -d ; echo
Son mot de passe kubectl -n keycloak get secret keycloak-initial-admin -o jsonpath='{.data.password}' | base64 -d ; echo
Realm labhttps://keycloak.lab.example.io/realms/lab
Découverte https://keycloak.lab.example.io/realms/lab/.well-known/openid-configuration
Utilisateur de démo demo, membre de k8s-admins
Son mot de passe kubectl -n keycloak get secret keycloak-demo-user -o jsonpath='{.data.password}' | base64 -d ; echo
Utilisateur en lecture seule viewer, membre de k8s-viewers
Son mot de passe kubectl -n keycloak get secret keycloak-viewer-user -o jsonpath='{.data.password}' | base64 -d ; echo

💡 Crée ton propre admin dans le realm master, puis supprime le Secret de bootstrap : kubectl -n keycloak delete secret keycloak-initial-admin.

🧪 Scénario — le realm survit à son cluster#

L'intérêt d'un realm déclaré, c'est qu'il revient. Détruis-le et laisse l'opérateur reconstruire :

# 1. Constater l'existant
kubectl -n keycloak get keycloakrealmimport lab -o jsonpath='{.status.conditions}' | jq

# 2. Supprimer tout le déploiement — le CR seulement, la base survit
kubectl -n keycloak delete keycloak keycloak
kubectl -n keycloak get pods -w            # l'opérateur reconstruit le StatefulSet

# 3. Réappliquer : même URL, même realm, mêmes utilisateurs — l'état était dans PostgreSQL
kubectl apply -f keycloak/02-keycloak.yaml
curl -sk https://keycloak.lab.example.io/realms/lab | jq .realm

Puis l'autre sens — une base vraiment vide :

kubectl -n keycloak delete keycloakrealmimport lab
kubectl -n keycloak delete cluster keycloak-db          # ⚠️ détruit les données
./keycloak/keycloak-up.sh <distro>                      # tout revient depuis les fichiers

Au second passage, le mot de passe de demo est le même : le script ne régénère pas un Secret qui existe déjà.

🚑 Dépannage#

  • L'issuer affiche un nom interne, ou le navigateur boucle à la connexionproxy.headers n'est pas xforwarded, ou hostname.hostname ne correspond pas au hostname de l'HTTPRoute : kubectl -n keycloak get keycloak keycloak -o jsonpath='{.spec.hostname}{"\n"}{.spec.proxy}'.
  • metadata.annotations: Too long en appliquant une CRD → apply côté client. La CRD keycloaks dépasse 500 Kio : utiliser kubectl apply --server-side.
  • L'opérateur démarre mais ne reconcilie rien → il n'est pas dans le namespace keycloak, et son ClusterRoleBinding (qui y désigne son ServiceAccount en dur) ne lui donne alors aucun droit : kubectl -n keycloak logs deploy/keycloak-operator.
  • keycloak-0 en CrashLoopBackOff au premier démarrage → presque toujours la base. Vérifier kubectl -n keycloak get cluster keycloak-db et les identifiants de keycloak-db-app. Un OOMKill pendant la migration Liquibase ressemble exactement à ça : monter spec.resources.limits.memory.
  • Le Job d'import du realm ne finit jamaiskubectl -n keycloak get keycloakrealmimport lab -o yaml puis les logs du Job. Un Secret de placeholder manquant fait échouer l'import avec un message obscur.
  • Tu as modifié 03-realm-lab.yaml et rien ne change → c'est normal. L'import n'écrase pas un realm existant. Supprimer le CR et le realm, puis réappliquer.
  • 404 sur la routekubectl -n keycloak describe httproute keycloak ; sectionName: https doit exister sur main-gateway et le hostname matcher le wildcard.

⚠️ Pièges#

  • proxy.headers: xforwarded n'est sûr que derrière un proxy que l'on maîtrise. Il fait confiance à X-Forwarded-Host. Si le Service était joignable directement, n'importe qui pourrait empoisonner les liens des e-mails de réinitialisation. Il reste en ClusterIP derrière la Gateway.
  • keycloak-initial-admin est un identifiant admin COMPLET, en clair tant qu'on le garde : lisible par tout ce qui a get secrets dans le namespace.
  • L'IdP est publié sur la VIP : tous les pairs Tailscale autorisés atteignent la page de connexion. La protection anti-brute-force est active dans le realm, mais un vrai mot de passe reste obligatoire.
  • L'import de realm est à sens unique. Il documente l'état initial, pas l'état courant. Toute modification faite dans la console dérive silencieusement de ce fichier — ce n'est pas du GitOps.
  • Détruire le cluster keycloak-db détruit toutes les identités. Le CR Keycloak ne porte aucun état ; PostgreSQL les porte tous, et ce lab n'en fait aucune sauvegarde (cf. ../cloudnative-pg/ pour les sauvegardes S3 + PITR).
  • Empiler ce composant sur des control planes trop justes en RAM finit par affamer etcd : Keycloak demande 768 Mio et monte plus haut au premier démarrage. lab.env (WK_MEM) est le bouton.

📚 Références#

🪪dex/ — Dex in front of Keycloak: kubectl login without a single certificate

The lab's kubeconfig stops being a credential. Dex publishes itself at dex.lab.example.io as the one OIDC issuer the Kubernetes apiserver trusts, and goes looking for the actual identity in the Keycloak lab realm. kubectl then logs in through a browser (kubectl oidc-login), and your rights come from a Keycloak group, not from a client certificate copied around.

🌐 lab.example.io is the repo's NEUTRAL domain (public): dex-up.sh swaps it for LAB_DOMAIN (lab.env) in the Dex values, the Keycloak client and the HTTPRoute. See ../README.md.

🎯 Purpose#

  • Show the only authentication mechanism Kubernetes offers that scales: OIDC. No user database in the cluster, no certificate to revoke, no kubeconfig to hand out.
  • Demonstrate that authorisation follows a group: put a user in k8s-admins in Keycloak, and cluster-admin follows — remove them and it is gone at the next token.
  • Explain, on a live cluster, why the API server is the hardest part of this chain to change, and how that plays out very differently on Talos and on kubeadm.

ℹ️ Standalone add-on, and it needs ../keycloak/ first: it uses the lab realm, its groups client scope and its demo user.

Why Dex when Keycloak already speaks OIDC#

Because the apiserver only knows one issuer, frozen on its command line, and changing that value restarts the control plane. Dex is the indirection: the apiserver only ever knows https://dex.lab.example.io, and everything that moves — adding a second directory, switching realm, rotating a client secret — happens in Dex's config, never on the control plane. That is exactly what a managed cluster does behind its SSO.

The setup in one sentence#

Three URLs must match, character for character: config.issuer in values.yaml, the hostnames: of the HTTPRoute, and --oidc-issuer-url on the apiserver. That string is signed into every token; a trailing slash is enough to have every token rejected with oidc: id token issued by a different provider.

📋 Prerequisites#

Prerequisite Why Verify
../keycloak/ installed, realm lab the upstream directory, its groups and its groups scope kubectl -n keycloak get keycloak,keycloakrealmimport
main-gateway with the https:443 listener (../envoy-gateway/) publishes the issuer over HTTPS kubectl get gateway -n envoy-gateway-system
Wildcard cert wildcard-lab-example-io-tls READY=True the apiserver refuses an issuer it cannot verify kubectl -n envoy-gateway-system get certificate
DNS dex.lab.example.io → 192.168.56.200 in DNS-only resolved by the apiserver and by your browser kubectl get gateway -n envoy-gateway-system
kubelogin (kubectl oidc-login) on the host drives the browser flow and caches the token kubectl oidc-login --version
Access to the control planes wiring the apiserver is not done from the API talosctl version / vagrant status
Nothing on the node side for Dex itself no hostPath, no privilege kubectl -n dex get pods

Installing kubelogin:

kubectl krew install oidc-login          # or: brew install int128/kubelogin/kubelogin
kubectl oidc-login --version             # pinned reference for this lab: v1.36.3

⚡ Install#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> keycloak dex      # <distro> = talos | kubeadm
./dex/dex-up.sh <distro>

Chart dex/dex 0.24.1 (app v2.44.0), pinned in the script via DEX_VERSION (can be overridden). Idempotent — with one deliberate exception: the client secrets are generated only if missing, because regenerating them would silently invalidate the client the Keycloak operator has already created.

⚠️ The script does not touch the API server. Wiring it to Dex restarts the control plane, and an unreachable issuer stops it from restarting at all. An add-on has no business doing that quietly. The script prints the exact commands for your distribution instead — see Wiring the API server.

Manual equivalent
kubectl create namespace dex
S=$(openssl rand -hex 32)
kubectl -n keycloak create secret generic dex-keycloak-client --from-literal=client-secret="$S"
kubectl -n dex      create secret generic dex-keycloak-client --from-literal=client-secret="$S"
kubectl -n dex create secret generic dex-kubernetes-client \
  --from-literal=client-secret="$(openssl rand -hex 32)"
dex/dex-up.sh <distro>                                 # step 2 creates the client with kcadm
helm repo add dex https://charts.dexidp.io && helm repo update dex
helm upgrade --install dex dex/dex -n dex --version 0.24.1 --values dex/values.yaml
kubectl apply -f dex/httproute.yaml
kubectl apply -f dex/rbac.yaml

🧬 Talos vs kubeadm#

This is the component where the two labs diverge the most — and the divergence is not in Dex, which is identical on both. It is in the one thing this repository does not own: the Kubernetes API server.

Talos Debian 13 + kubeadm
What the apiserver is a static pod whose manifest Talos generates from the machine configuration a static pod whose manifest lives on disk, generated by kubeadm
How you change its flags talosctl patch mcthe configuration is an API edit the kubeadm-config ConfigMap, then re-run kubeadm init phase on each node
Access needed none beyond talosctl: no SSH exists on Talos a shell on every control plane (vagrant ssh k8s-cpN)
Shape of extraArgs a map (key: value), Talos v1alpha1 a list of {name, value}, kubeadm v1beta4 (Kubernetes ≥ 1.31)
Restart Talos rewrites the manifest and restarts the apiserver itself kubeadm rewrites the manifest, the kubelet notices and restarts the pod
Where the OIDC CA goes (SELF_SIGNED=true) a machine.files entry plus a cluster.apiServer.extraVolumes mount: / is read-only and the static pod sees nothing you have not mounted a plain cp into /etc/kubernetes/pki/, already mounted into the pod — nothing else to declare
Profile variables APISERVER_OIDC_PATCH, APISERVER_OIDC_MECHANISM, apiserver_oidc_commands() same three, other values
Patch shipped here apiserver-oidc.talos.yaml apiserver-oidc.kubeadm.yaml

Everything else — the Dex chart, the Keycloak client CR, the HTTPRoute, the RBAC bindings — is byte-for-byte identical on both labs.

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. The namespace and the two client secrets#

The dex client secret has to exist in two namespaces: Keycloak reads it in keycloak (the KeycloakOIDCClient CR lives there), Dex reads it in dex. Secrets do not cross namespaces.

kubectl create namespace dex
S=$(openssl rand -hex 32)
kubectl -n keycloak create secret generic dex-keycloak-client --from-literal=client-secret="$S"
kubectl -n dex      create secret generic dex-keycloak-client --from-literal=client-secret="$S"
kubectl -n dex create secret generic dex-kubernetes-client \
  --from-literal=client-secret="$(openssl rand -hex 32)"

2. The Keycloak client, with kcadm#

# kcadm lives INSIDE the Keycloak image: no DNS, no CA, no curl on the host
KC="kubectl -n keycloak exec -i keycloak-0 -- /opt/keycloak/bin/kcadm.sh"
$KC config credentials --server http://localhost:8080 --realm master \
  --user "$(kubectl -n keycloak get secret keycloak-initial-admin -o jsonpath='{.data.username}' | base64 -d)" \
  --password "$(kubectl -n keycloak get secret keycloak-initial-admin -o jsonpath='{.data.password}' | base64 -d)"

$KC create clients -r lab -f - <<EOF
{ "clientId": "dex", "enabled": true, "publicClient": false,
  "secret": "$(kubectl -n dex get secret dex-keycloak-client -o jsonpath='{.data.client-secret}' | base64 -d)",
  "standardFlowEnabled": true, "directAccessGrantsEnabled": false,
  "implicitFlowEnabled": false, "serviceAccountsEnabled": false,
  "redirectUris": [ "https://dex.${LAB_DOMAIN}/callback" ] }
EOF
$KC get clients -r lab -q clientId=dex --fields clientId,enabled,redirectUris

The clientId must match clientID: dex in the Dex connector exactly.

⚠️ Why not the KeycloakOIDCClient CRD. Keycloak 26.7 ships one, and it would be the natural way to declare this client. It does not work, and it fails silently: the CR applies, kubectl apply prints configured, the object exists — and it is never reconciled, so the client never appears in the realm. The whole thing only surfaces at login, as invalid_client. Two independent blockers, measured on 26.7.0:

  1. the server needs the client-admin-api:v2 feature (see ../keycloak/02-keycloak.yaml), and the operator caches the server's feature list — it keeps reporting the feature as missing until the operator itself is restarted (kubectl -n keycloak rollout restart deploy/keycloak-operator);
  2. past that, KeycloakClientBaseController looks up a Secret named <keycloak-cr-name>-admin, while the operator's own bootstrap Secret is keycloak-initial-admin — a different name. Creating keycloak-admin with username/password is not enough either: the controller then dies on Cannot invoke "String.getBytes(...)" because "src" is null, so it expects other keys (most likely a service account, client-id/client-secret).

A working client that nothing reconciles beats a declarative one that is never created. When upstream fixes the CRD, dex-up.sh step 2 goes back to a kubectl apply.

3. Dex itself#

helm repo add dex https://charts.dexidp.io && helm repo update dex
sed "s/lab\.example\.io/${LAB_DOMAIN}/g" dex/values.yaml > /tmp/dex-values.yaml
helm upgrade --install dex dex/dex -n dex --version 0.24.1 --values /tmp/dex-values.yaml
kubectl -n dex rollout status deploy/dex --timeout=300s
kubectl -n dex logs deploy/dex | head -20        # "config using connector: keycloak"

4. The route and the RBAC bindings#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" dex/httproute.yaml | kubectl apply -f -
kubectl apply -f dex/rbac.yaml
curl -sk "https://dex.${LAB_DOMAIN}/.well-known/openid-configuration" | jq '.issuer, .jwks_uri'

The issuer must read exactly https://dex.<LAB_DOMAIN> — no trailing slash.

5. Wiring the API server#

See the dedicated section below: this is the step the script refuses to run for you.

6. The kubectl context#

CTX=$(kubectl config current-context)
CLUSTER=$(kubectl config view -o jsonpath="{.contexts[?(@.name==\"$CTX\")].context.cluster}")
kubectl config set-credentials oidc \
  --exec-api-version=client.authentication.k8s.io/v1beta1 \
  --exec-command=kubectl \
  --exec-arg=oidc-login --exec-arg=get-token \
  --exec-arg=--oidc-issuer-url=https://dex.${LAB_DOMAIN} \
  --exec-arg=--oidc-client-id=kubernetes \
  --exec-arg=--oidc-client-secret=$(kubectl -n dex get secret dex-kubernetes-client \
      -o jsonpath='{.data.client-secret}' | base64 -d) \
  --exec-arg=--oidc-extra-scope=groups --exec-arg=--oidc-extra-scope=email
kubectl config set-context oidc --cluster="$CLUSTER" --user=oidc
kubectl --context=oidc get nodes

That last command opens a browser: Dex offers the Keycloak connector, Keycloak asks for demo and its password, and kubectl comes back with a token. Nothing was copied, nothing was signed by hand.

💡 With SELF_SIGNED=true, add --exec-arg=--certificate-authority=<path to the lab CA> so kubelogin trusts dex.<LAB_DOMAIN> too. --insecure-skip-tls-verify works, and teaches the wrong reflex.

🔐 Wiring the API server#

This is the only step of the repository that changes the control plane, so it is the only one left in your hands. Read this before running anything:

  • It adds an authenticator, it removes none. The lab's kubeconfig and every component's client certificate keep working — which is what makes the operation reversible.
  • A wrong --oidc-issuer-url (unreachable, or with an untrusted certificate) stops the apiserver from starting. Do one control plane at a time and check kubectl get --raw=/readyz in between; as long as one control plane is healthy the cluster stays administrable.
  • To roll back: remove the flags the same way you added them.

Talos#

for ip in $(kubectl get nodes -l node-role.kubernetes.io/control-plane \
              -o jsonpath='{range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{" "}{end}'); do
  talosctl -n "$ip" patch mc --patch @dex/apiserver-oidc.talos.yaml
  kubectl get --raw=/readyz && echo
done
Untrusted wildcard (SELF_SIGNED=true or ACME staging): also hand the apiserver the lab CA

/ is read-only on Talos and the static pod sees only what you mount. Two additions therefore: the file, and the volume that exposes it.

# The wildcard Secret works in EVERY mode, and that is the point: `tls.crt` always carries its
# own trust anchor — leaf + local CA when self-signed (selfsigned-up.sh concatenates them on
# purpose), leaf + intermediate (+ root) with ACME. `_out/self-signed/ca.crt` only exists in one
# of the three modes, so do not reach for it.
kubectl -n envoy-gateway-system get secret "wildcard-$(echo "$LAB_DOMAIN" | tr . -)-tls" \
  -o jsonpath='{.data.tls\.crt}' | base64 -d > /tmp/lab-ca.crt
CA=$(sed 's/^/          /' /tmp/lab-ca.crt)                            # indent the PEM
cat > /tmp/oidc-ca.talos.yaml <<EOF
machine:
  files:
    - path: /var/lib/oidc/ca.crt
      permissions: 0o644
      op: create
      content: |
$CA
cluster:
  apiServer:
    extraArgs:
      oidc-ca-file: /etc/kubernetes/oidc/ca.crt
    extraVolumes:
      - hostPath: /var/lib/oidc
        mountPath: /etc/kubernetes/oidc
        readonly: true
EOF
talosctl -n <control-plane IP> patch mc --patch @/tmp/oidc-ca.talos.yaml

op: create refuses to overwrite: on a second run, use overwrite.

kubeadm#

The apiserver manifest is a file on each control plane, regenerated by kubeadm from the kubeadm-config ConfigMap. Editing the manifest directly works — until the next kubeadm upgrade silently overwrites it. So the ConfigMap is the source of truth:

# 1. merge the apiServer block of dex/apiserver-oidc.kubeadm.yaml into ClusterConfiguration
kubectl -n kube-system edit configmap kubeadm-config

# 2. on EACH control plane, regenerate the static pod manifest from that ConfigMap
vagrant ssh k8s-cp1 -c '
  kubectl -n kube-system get cm kubeadm-config -o jsonpath="{.data.ClusterConfiguration}" \
    | sudo tee /tmp/kubeadm.yaml >/dev/null
  sudo kubeadm init phase control-plane apiserver --config /tmp/kubeadm.yaml'
kubectl get --raw=/readyz && echo
Untrusted wildcard (SELF_SIGNED=true or ACME staging): also hand the apiserver the lab CA

/etc/kubernetes/pki is already mounted into the static pod, so the file is all it takes:

vagrant ssh k8s-cp1 -c 'sudo tee /etc/kubernetes/pki/oidc-ca.crt >/dev/null' \
  < /tmp/lab-ca.crt        # extracted from the wildcard Secret, see the Talos box above
# then add to the ConfigMap, alongside the other flags:
#   - name: oidc-ca-file
#     value: /etc/kubernetes/pki/oidc-ca.crt

💡 The simplest path is to have no CA problem at all: with SELF_SIGNED=false the wildcard is issued by Let's Encrypt, publicly trusted, and neither the apiserver nor kubelogin needs anything. See ../cert-manager/README.md.

🔧 What the script does#

  1. creates the dex namespace and the two client secrets — generating them only if missing;
  2. applies the KeycloakOIDCClient CR with the domain substituted, and waits for Ready;
  3. installs the Dex chart with the rendered values, and waits for the rollout;
  4. applies the HTTPRoute and the two ClusterRoleBindings;
  5. prints the API-server wiring commands for the detected distribution, then the kubectl context — it runs neither.

Files#

File Purpose
(no client manifest) the dex client is created by dex-up.sh step 2 with kcadmSTANDARD flow only, one exact redirect URI. The KeycloakOIDCClient CRD is not usable, see above
values.yaml Dex: public issuer, Kubernetes storage, oidc connector to the realm, kubernetes static client; both secrets come from environment variables
httproute.yaml HTTPS HTTPRoute dex.lab.example.iodex:5556, sectionName: https
rbac.yaml oidc:k8s-adminscluster-admin, oidc:k8s-viewersview
apiserver-oidc.talos.yaml Talos machine-config patch (talosctl patch mc)
apiserver-oidc.kubeadm.yaml ClusterConfiguration fragment to merge into kubeadm-config
dex-up.sh the all-in-one install (idempotent), minus the control-plane change

✅ Verify#

kubectl -n dex get pods,httproute                       # dex Running, route Accepted
kubectl -n keycloak exec -i keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get clients -r lab -q clientId=dex --fields clientId,enabled,redirectUris   # the client exists
curl -sk https://dex.lab.example.io/.well-known/openid-configuration | jq .issuer

# the apiserver really sees the flags (Talos and kubeadm alike):
kubectl -n kube-system get pod -l component=kube-apiserver \
  -o jsonpath='{.items[0].spec.containers[0].command}' | tr ',' '\n' | grep oidc

# the whole chain, in one command:
kubectl --context=oidc auth whoami                      # oidc:demo@lab.example.io, oidc:k8s-admins
kubectl --context=oidc auth can-i '*' '*' --all-namespaces   # yes

🧪 The two accounts — the same login, two sets of rights#

The realm ships two users, and the second one is what makes the demonstration falsifiable: if both could do the same things, nothing would prove the groups claim is actually being read.

User Group ClusterRole Password
demo k8s-admins cluster-admin kubectl -n keycloak get secret keycloak-demo-user -o jsonpath='{.data.password}' | base64 -d ; echo
viewer k8s-viewers view kubectl -n keycloak get secret keycloak-viewer-user -o jsonpath='{.data.password}' | base64 -d ; echo

Not one line of rbac.yaml names a person: the subjects are groups (kind: Group, oidc:k8s-admins / oidc:k8s-viewers). Adding a colleague is a group membership in Keycloak, never a kubectl command.

A second context, so both can coexist — same cluster, same issuer, another account. The --oidc-use-pkce and cache separation matter: without a distinct --token-cache-dir, kubelogin reuses demo's cached token and the "viewer" context silently stays admin.

kubectl config set-credentials oidc-viewer \
  --exec-api-version=client.authentication.k8s.io/v1beta1 \
  --exec-command=kubectl \
  --exec-arg=oidc-login --exec-arg=get-token \
  --exec-arg=--oidc-issuer-url=https://dex.lab.example.io \
  --exec-arg=--oidc-client-id=kubernetes \
  --exec-arg=--oidc-client-secret="$(kubectl -n dex get secret dex-kubernetes-client -o jsonpath='{.data.client-secret}' | base64 -d)" \
  --exec-arg=--oidc-extra-scope=groups --exec-arg=--oidc-extra-scope=email \
  --exec-arg=--token-cache-dir=~/.kube/cache/oidc-login-viewer
kubectl config set-context oidc-viewer --cluster=<your-cluster> --user=oidc-viewer

The check that matters is the one that must fail:

kubectl --context=oidc-viewer auth whoami            # oidc:viewer@lab.example.io, oidc:k8s-viewers
kubectl --context=oidc-viewer auth can-i list pods   # yes
kubectl --context=oidc-viewer auth can-i create pods # no
kubectl --context=oidc-viewer auth can-i get secrets # no  <- `view` excludes Secrets

⚠️ view is not "read everything". It grants nothing on cluster-scoped objects, so kubectl --context=oidc-viewer get nodes is Forbidden — the ClusterRole behaving correctly, not a broken login. Test with get pods, which view does allow.

Switching from one account to the other#

Two caches keep you logged in, and the second one is the one nobody thinks of.

# 1. kubelogin's token cache — per context, hence the distinct --token-cache-dir above
rm -rf ~/.kube/cache/oidc-login ~/.kube/cache/oidc-login-viewer

# 2. Keycloak's SSO session — without this, the browser flashes open and closes, and you are
#    silently logged back in as the PREVIOUS user. Nothing says so.
kubectl -n keycloak exec -i keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  config credentials --server http://localhost:8080 --realm master \
  --user "$(kubectl -n keycloak get secret keycloak-initial-admin -o jsonpath='{.data.username}' | base64 -d)" \
  --password "$(kubectl -n keycloak get secret keycloak-initial-admin -o jsonpath='{.data.password}' | base64 -d)"
kubectl -n keycloak exec -i keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  create realms/lab/logout-all                       # closes every session of the realm

A private browser window achieves the same thing without touching any state, and is the quickest way to hold both identities at once.

⚠️ A logout does not revoke a token that has already been issued. The apiserver checks a signature and an expiry, not a session: after logout-all, a token sitting in kubelogin's cache keeps working until it expires. To actually cut an account off, disable the user (enabled: false) — or delete the local cache. logout-all only stops the NEXT login from being silent.

💡 auth whoami returning the previous user is the symptom of a live SSO session, not of a misconfigured context. That is the check that tells the two apart.

🧪 Scenario — authorisation lives in Keycloak, not in the cluster#

# 1. demo is in k8s-admins: full rights
kubectl --context=oidc auth can-i delete nodes                  # yes

# 2. In the Keycloak console (https://keycloak.<LAB_DOMAIN>/admin/), realm `lab`:
#    Users -> demo -> Groups -> leave k8s-admins, join k8s-viewers

# 3. Force a new token — the old one is cached and still valid until it expires
rm -rf ~/.kube/cache/oidc-login
kubectl --context=oidc auth whoami                              # oidc:k8s-viewers
kubectl --context=oidc auth can-i delete nodes                  # no
kubectl --context=oidc get pods -A                              # still works: `view`

Not one kubectl command was needed to change those rights, and nothing was revoked in the cluster. That is the whole point — and the cache is also the whole caveat: a token already issued keeps its groups until it expires. OIDC de-provisioning is never instant.

🚑 Troubleshooting#

  • oidc: id token issued by a different provider → the three URLs disagree. Compare config.issuer (kubectl -n dex get secret dex -o jsonpath='{.data.config\.yaml}' | base64 -d), the HTTPRoute hostname, and --oidc-issuer-url on the apiserver. A trailing / is enough.
  • The apiserver will not restart after the patch → almost always the issuer: unreachable, or a certificate it does not trust. On Talos, talosctl -n <ip> logs kubelet; on kubeadm, sudo crictl logs $(sudo crictl ps -a --name kube-apiserver -q | head -1). Undo the flags.
  • Login works but every command is Forbidden → the groups are missing from the token. Check in order: the groups scope requested by Dex (values.yaml), insecureEnableGroups: true on the connector, the groups client scope in the realm (../keycloak/), and the oidc: prefix in rbac.yaml.
  • kubectl auth whoami shows the right user but no group → same causes, and check that the Keycloak client really was assigned the groups scope: the realm assigns it to new clients (defaultDefaultClientScopes); a client created before that setting does not have it.
  • The browser never comes back / redirect_uri_mismatch → kubelogin listens on :8000. The URI must appear in both staticClients[].redirectURIs (Dex) and the Keycloak client's redirectUris — the latter only lists https://dex.<LAB_DOMAIN>/callback, which is Dex's own callback, not kubectl's.
  • x509: certificate signed by unknown authoritySELF_SIGNED=true and someone in the chain lacks the lab CA: the apiserver (oidc-ca-file), kubelogin (--certificate-authority), or Dex when it calls Keycloak.
  • The Dex pod restarts in a loopkubectl -n dex logs deploy/dex. A missing secret, or an unexpanded $KEYCLOAK_CLIENT_SECRET, shows up as a connector failing to start.

⚠️ Pitfalls#

  • cluster-admin is granted to a group, so anyone Keycloak puts in it is cluster admin. The IdP is now part of the cluster's trust boundary; whoever administers the realm administers Kubernetes.
  • Revocation is not immediate. A token already handed out stays valid until it expires, whatever you change in Keycloak. Kubernetes does not check anything on the IdP side per request.
  • The oidc: prefix is load-bearing. Drop it and a directory that declares a system:masters group takes over the cluster. If you change it, change rbac.yaml too.
  • Never remove the certificate authenticators. OIDC adds itself to them; a cluster whose only door is an IdP is a cluster you cannot get back into when the IdP is down — and here the IdP runs inside that cluster.
  • insecureEnableGroups is badly named but required: without it, Dex's generic OIDC connector drops upstream groups on the floor and nothing says so.
  • The client secrets are generated once. Re-running the script does not rotate them, on purpose: regenerating the dex secret on one side only would break the client with no error in sight. Rotate deliberately — both namespaces, then restart Dex.
  • Editing /etc/kubernetes/manifests/kube-apiserver.yaml by hand on kubeadm survives until the next kubeadm upgrade, which regenerates it from kubeadm-config.

📚 References#

🪪dex/ — Dex devant Keycloak : kubectl sans le moindre certificat

Le kubeconfig du lab cesse d'être un identifiant. Dex se publie sous dex.lab.example.io comme l'unique émetteur OIDC auquel le serveur d'API fait confiance, et va chercher l'identité réelle dans le realm Keycloak lab. kubectl se connecte alors par un navigateur (kubectl oidc-login), et tes droits viennent d'un groupe Keycloak, plus d'un certificat client recopié de machine en machine.

🌐 lab.example.io est le domaine NEUTRE du dépôt (public) : dex-up.sh le remplace par LAB_DOMAIN (lab.env) dans les values Dex, le client Keycloak et l'HTTPRoute. Cf. ../LISEZ-MOI.md.

🎯 À quoi ça sert#

  • Montrer le seul mécanisme d'authentification de Kubernetes qui passe à l'échelle : OIDC. Aucune base d'utilisateurs dans le cluster, aucun certificat à révoquer, aucun kubeconfig à distribuer.
  • Démontrer que l'autorisation suit un groupe : mets un utilisateur dans k8s-admins côté Keycloak et cluster-admin suit — sors-l'en et c'est fini au jeton suivant.
  • Expliquer, sur un cluster vivant, pourquoi le serveur d'API est le maillon le plus dur à modifier de cette chaîne, et à quel point ça se joue différemment sur Talos et sur kubeadm.

ℹ️ Addon à part, et il exige ../keycloak/ d'abord : il s'appuie sur le realm lab, son scope groups et son utilisateur demo.

Pourquoi Dex alors que Keycloak parle déjà OIDC#

Parce que l'apiserver ne connaît qu'un seul émetteur, figé dans sa ligne de commande, et que changer cette valeur redémarre le control plane. Dex est le point d'indirection : l'apiserver ne connaît jamais que https://dex.lab.example.io, et tout ce qui bouge — ajouter un second annuaire, changer de realm, faire tourner un secret de client — se fait dans la configuration de Dex, jamais sur le control plane. C'est exactement ce que fait un cluster managé derrière son SSO.

Le montage en une phrase#

Trois URL doivent coïncider au caractère près : config.issuer de values.yaml, le hostnames: de l'HTTPRoute, et --oidc-issuer-url de l'apiserver. Cette chaîne est signée dans chaque jeton ; un / final suffit à les faire tous rejeter avec oidc: id token issued by a different provider.

📋 Prérequis#

Prérequis Pourquoi Vérifier
../keycloak/ installé, realm lab l'annuaire amont, ses groupes et son scope groups kubectl -n keycloak get keycloak,keycloakrealmimport
main-gateway avec l'écouteur https:443 (../envoy-gateway/) publie l'émetteur en HTTPS kubectl get gateway -n envoy-gateway-system
Cert wildcard wildcard-lab-example-io-tls READY=True l'apiserver refuse un émetteur qu'il ne peut pas vérifier kubectl -n envoy-gateway-system get certificate
DNS dex.lab.example.io → 192.168.56.200 en DNS-only résolu par l'apiserver et par ton navigateur kubectl get gateway -n envoy-gateway-system
kubelogin (kubectl oidc-login) sur l'hôte pilote le flux navigateur et met le jeton en cache kubectl oidc-login --version
Accès aux control planes câbler l'apiserver ne se fait pas depuis l'API talosctl version / vagrant status
Rien côté nodes pour Dex lui-même ni hostPath, ni privilège kubectl -n dex get pods

Installer kubelogin :

kubectl krew install oidc-login          # ou : brew install int128/kubelogin/kubelogin
kubectl oidc-login --version             # version de référence de ce lab : v1.36.3

⚡ Installation#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> keycloak dex      # <distro> = talos | kubeadm
./dex/dex-up.sh <distro>

Chart dex/dex 0.24.1 (app v2.44.0), épinglé dans le script via DEX_VERSION (surchargeable). Idempotent — avec une exception assumée : les secrets de client ne sont générés que s'ils manquent, parce que les régénérer invaliderait en silence le client que l'opérateur Keycloak a déjà créé.

⚠️ Le script ne touche pas au serveur d'API. Le brancher sur Dex redémarre le control plane, et un émetteur injoignable l'empêche carrément de redémarrer. Un addon n'a pas à faire ça en douce. Le script affiche à la place les commandes exactes pour ta distribution — cf. Câbler le serveur d'API.

Équivalent manuel
kubectl create namespace dex
S=$(openssl rand -hex 32)
kubectl -n keycloak create secret generic dex-keycloak-client --from-literal=client-secret="$S"
kubectl -n dex      create secret generic dex-keycloak-client --from-literal=client-secret="$S"
kubectl -n dex create secret generic dex-kubernetes-client \
  --from-literal=client-secret="$(openssl rand -hex 32)"
dex/dex-up.sh <distro>                                 # l'étape 2 crée le client avec kcadm
helm repo add dex https://charts.dexidp.io && helm repo update dex
helm upgrade --install dex dex/dex -n dex --version 0.24.1 --values dex/values.yaml
kubectl apply -f dex/httproute.yaml
kubectl apply -f dex/rbac.yaml

🧬 Talos vs kubeadm#

C'est le composant où les deux labs divergent le plus — et la divergence n'est pas dans Dex, identique des deux côtés. Elle est dans la seule chose que ce dépôt ne possède pas : le serveur d'API Kubernetes.

Talos Debian 13 + kubeadm
Ce qu'est l'apiserver un pod statique dont Talos génère le manifeste depuis la configuration machine un pod statique dont le manifeste est un fichier sur le disque, généré par kubeadm
Comment changer ses flags talosctl patch mcla configuration est une API éditer la ConfigMap kubeadm-config, puis relancer kubeadm init phase sur chaque node
Accès nécessaire rien de plus que talosctl : il n'y a pas de SSH sur Talos une session sur chaque control plane (vagrant ssh k8s-cpN)
Forme de extraArgs une map (clé: valeur), Talos v1alpha1 une liste de {name, value}, kubeadm v1beta4 (Kubernetes ≥ 1.31)
Redémarrage Talos réécrit le manifeste et redémarre l'apiserver lui-même kubeadm réécrit le manifeste, le kubelet le voit et redémarre le pod
Où va l'AC OIDC (SELF_SIGNED=true) une entrée machine.files plus un montage cluster.apiServer.extraVolumes : / est en lecture seule et le pod statique ne voit que ce qu'on lui monte un simple cp dans /etc/kubernetes/pki/, déjà monté dans le pod — rien d'autre à déclarer
Variables de profil APISERVER_OIDC_PATCH, APISERVER_OIDC_MECHANISM, apiserver_oidc_commands() les mêmes trois, d'autres valeurs
Patch fourni ici apiserver-oidc.talos.yaml apiserver-oidc.kubeadm.yaml

Tout le reste — le chart Dex, le CR du client Keycloak, l'HTTPRoute, les liaisons RBAC — est identique à l'octet près sur les deux labs.

🎓 Pas à pas guidé#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord ton shell (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou le tien (cf. lab.env du lab)

1. Le namespace et les deux secrets de client#

Le secret du client dex doit exister dans deux namespaces : Keycloak le lit dans keycloak (le CR KeycloakOIDCClient y vit), Dex le lit dans dex. Un Secret ne franchit pas les namespaces.

kubectl create namespace dex
S=$(openssl rand -hex 32)
kubectl -n keycloak create secret generic dex-keycloak-client --from-literal=client-secret="$S"
kubectl -n dex      create secret generic dex-keycloak-client --from-literal=client-secret="$S"
kubectl -n dex create secret generic dex-kubernetes-client \
  --from-literal=client-secret="$(openssl rand -hex 32)"

2. Le client Keycloak, avec kcadm#

# kcadm vit DANS l'image Keycloak : ni DNS, ni AC, ni curl sur l'hôte
KC="kubectl -n keycloak exec -i keycloak-0 -- /opt/keycloak/bin/kcadm.sh"
$KC config credentials --server http://localhost:8080 --realm master \
  --user "$(kubectl -n keycloak get secret keycloak-initial-admin -o jsonpath='{.data.username}' | base64 -d)" \
  --password "$(kubectl -n keycloak get secret keycloak-initial-admin -o jsonpath='{.data.password}' | base64 -d)"

$KC create clients -r lab -f - <<EOF
{ "clientId": "dex", "enabled": true, "publicClient": false,
  "secret": "$(kubectl -n dex get secret dex-keycloak-client -o jsonpath='{.data.client-secret}' | base64 -d)",
  "standardFlowEnabled": true, "directAccessGrantsEnabled": false,
  "implicitFlowEnabled": false, "serviceAccountsEnabled": false,
  "redirectUris": [ "https://dex.${LAB_DOMAIN}/callback" ] }
EOF
$KC get clients -r lab -q clientId=dex --fields clientId,enabled,redirectUris

Le clientId doit correspondre exactement au clientID: dex du connecteur Dex.

⚠️ Pourquoi pas le CRD KeycloakOIDCClient. Keycloak 26.7 en fournit un, et ce serait la façon naturelle de déclarer ce client. Il ne fonctionne pas, et il échoue en silence : le CR s'applique, kubectl apply affiche configured, l'objet existe — et il n'est jamais réconcilié, donc le client n'apparaît jamais dans le realm. Tout ne se voit qu'au login, sous la forme d'un invalid_client. Deux blocages indépendants, mesurés sur 26.7.0 :

  1. le serveur exige la feature client-admin-api:v2 (cf. ../keycloak/02-keycloak.yaml), et l'opérateur met en cache la liste des features du serveur — il continue de la signaler absente jusqu'à ce qu'on redémarre l'opérateur lui-même (kubectl -n keycloak rollout restart deploy/keycloak-operator) ;
  2. ensuite, KeycloakClientBaseController cherche un Secret nommé <nom-du-CR-keycloak>-admin, alors que le Secret d'amorçage de l'opérateur s'appelle keycloak-initial-admin — un autre nom. Créer keycloak-admin avec username/password ne suffit pas non plus : le contrôleur meurt alors sur Cannot invoke "String.getBytes(...)" because "src" is null, il attend donc d'autres clés (vraisemblablement un service account, client-id/client-secret).

Un client qui fonctionne sans être réconcilié vaut mieux qu'un client déclaratif qui n'est jamais créé. Quand l'amont corrigera le CRD, l'étape 2 de dex-up.sh redeviendra un kubectl apply.

3. Dex lui-même#

helm repo add dex https://charts.dexidp.io && helm repo update dex
sed "s/lab\.example\.io/${LAB_DOMAIN}/g" dex/values.yaml > /tmp/dex-values.yaml
helm upgrade --install dex dex/dex -n dex --version 0.24.1 --values /tmp/dex-values.yaml
kubectl -n dex rollout status deploy/dex --timeout=300s
kubectl -n dex logs deploy/dex | head -20        # « config using connector: keycloak »

4. La route et les liaisons RBAC#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" dex/httproute.yaml | kubectl apply -f -
kubectl apply -f dex/rbac.yaml
curl -sk "https://dex.${LAB_DOMAIN}/.well-known/openid-configuration" | jq '.issuer, .jwks_uri'

L'issuer doit valoir exactement https://dex.<LAB_DOMAIN> — sans / final.

5. Câbler le serveur d'API#

Voir la section dédiée ci-dessous : c'est l'étape que le script refuse de faire à ta place.

6. Le contexte kubectl#

CTX=$(kubectl config current-context)
CLUSTER=$(kubectl config view -o jsonpath="{.contexts[?(@.name==\"$CTX\")].context.cluster}")
kubectl config set-credentials oidc \
  --exec-api-version=client.authentication.k8s.io/v1beta1 \
  --exec-command=kubectl \
  --exec-arg=oidc-login --exec-arg=get-token \
  --exec-arg=--oidc-issuer-url=https://dex.${LAB_DOMAIN} \
  --exec-arg=--oidc-client-id=kubernetes \
  --exec-arg=--oidc-client-secret=$(kubectl -n dex get secret dex-kubernetes-client \
      -o jsonpath='{.data.client-secret}' | base64 -d) \
  --exec-arg=--oidc-extra-scope=groups --exec-arg=--oidc-extra-scope=email
kubectl config set-context oidc --cluster="$CLUSTER" --user=oidc
kubectl --context=oidc get nodes

Cette dernière commande ouvre un navigateur : Dex propose le connecteur Keycloak, Keycloak demande demo et son mot de passe, et kubectl revient avec un jeton. Rien n'a été recopié, rien n'a été signé à la main.

💡 Avec SELF_SIGNED=true, ajoute --exec-arg=--certificate-authority=<chemin de l'AC du lab> pour que kubelogin fasse confiance à dex.<LAB_DOMAIN> lui aussi. --insecure-skip-tls-verify marche, et enseigne le mauvais réflexe.

🔐 Câbler le serveur d'API#

C'est la seule étape du dépôt qui modifie le control plane, donc la seule qu'on te laisse en main. À lire avant de lancer quoi que ce soit :

  • Elle ajoute un authentifieur, elle n'en retire aucun. Le kubeconfig du lab et les certificats client des composants continuent de marcher — c'est ce qui rend l'opération réversible.
  • Un --oidc-issuer-url faux (injoignable, ou avec un certificat non trusté) empêche l'apiserver de démarrer. Un control plane à la fois, en vérifiant kubectl get --raw=/readyz entre chaque ; tant qu'un control plane est sain, le cluster reste administrable.
  • Pour revenir en arrière : retirer les flags de la même façon qu'on les a posés.

Talos#

for ip in $(kubectl get nodes -l node-role.kubernetes.io/control-plane \
              -o jsonpath='{range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{" "}{end}'); do
  talosctl -n "$ip" patch mc --patch @dex/apiserver-oidc.talos.yaml
  kubectl get --raw=/readyz && echo
done
Wildcard non trusté (SELF_SIGNED=true ou ACME staging) : donner aussi l'AC du lab à l'apiserver

/ est en lecture seule sur Talos et le pod statique ne voit que ce qu'on lui monte. Deux ajouts, donc : le fichier, et le volume qui l'expose.

# Le Secret wildcard marche dans TOUS les modes, et c'est tout l'intérêt : `tls.crt` porte
# toujours son propre point d'ancrage — feuille + AC locale en self-signed (selfsigned-up.sh les
# concatène exprès), feuille + intermédiaire (+ racine) en ACME. `_out/self-signed/ca.crt`
# n'existe que dans un des trois modes : ne pas s'en servir.
kubectl -n envoy-gateway-system get secret "wildcard-$(echo "$LAB_DOMAIN" | tr . -)-tls" \
  -o jsonpath='{.data.tls\.crt}' | base64 -d > /tmp/lab-ca.crt
CA=$(sed 's/^/          /' /tmp/lab-ca.crt)                            # indenter le PEM
cat > /tmp/oidc-ca.talos.yaml <<EOF
machine:
  files:
    - path: /var/lib/oidc/ca.crt
      permissions: 0o644
      op: create
      content: |
$CA
cluster:
  apiServer:
    extraArgs:
      oidc-ca-file: /etc/kubernetes/oidc/ca.crt
    extraVolumes:
      - hostPath: /var/lib/oidc
        mountPath: /etc/kubernetes/oidc
        readonly: true
EOF
talosctl -n <IP du control plane> patch mc --patch @/tmp/oidc-ca.talos.yaml

op: create refuse d'écraser : au second passage, utiliser overwrite.

kubeadm#

Le manifeste de l'apiserver est un fichier sur chaque control plane, régénéré par kubeadm depuis la ConfigMap kubeadm-config. Éditer le manifeste directement marche — jusqu'au prochain kubeadm upgrade qui l'écrase en silence. La ConfigMap est donc la source de vérité :

# 1. fusionner le bloc apiServer de dex/apiserver-oidc.kubeadm.yaml dans ClusterConfiguration
kubectl -n kube-system edit configmap kubeadm-config

# 2. sur CHAQUE control plane, régénérer le manifeste du pod statique depuis cette ConfigMap
vagrant ssh k8s-cp1 -c '
  kubectl -n kube-system get cm kubeadm-config -o jsonpath="{.data.ClusterConfiguration}" \
    | sudo tee /tmp/kubeadm.yaml >/dev/null
  sudo kubeadm init phase control-plane apiserver --config /tmp/kubeadm.yaml'
kubectl get --raw=/readyz && echo
Wildcard non trusté (SELF_SIGNED=true ou ACME staging) : donner aussi l'AC du lab à l'apiserver

/etc/kubernetes/pki est déjà monté dans le pod statique : le fichier suffit.

vagrant ssh k8s-cp1 -c 'sudo tee /etc/kubernetes/pki/oidc-ca.crt >/dev/null' \
  < /tmp/lab-ca.crt        # extrait du Secret wildcard, cf. l'encart Talos ci-dessus
# puis ajouter dans la ConfigMap, à côté des autres flags :
#   - name: oidc-ca-file
#     value: /etc/kubernetes/pki/oidc-ca.crt

💡 Le chemin le plus simple est de n'avoir aucun problème d'AC : avec SELF_SIGNED=false, le wildcard est émis par Let's Encrypt, publiquement trusté, et ni l'apiserver ni kubelogin n'ont besoin de quoi que ce soit. Cf. ../cert-manager/LISEZ-MOI.md.

🔧 Ce que fait le script#

  1. crée le namespace dex et les deux secrets de client — en ne les générant que s'ils manquent ;
  2. applique le CR KeycloakOIDCClient avec le domaine substitué, et attend Ready ;
  3. installe le chart Dex avec les values rendues, et attend le déroulement ;
  4. applique l'HTTPRoute et les deux ClusterRoleBinding ;
  5. affiche les commandes de câblage de l'apiserver pour la distribution détectée, puis le contexte kubectl — il n'exécute ni l'un ni l'autre.

Fichiers#

Fichier Rôle
(aucun manifeste de client) le client dex est créé par l'étape 2 de dex-up.sh avec kcadm — flux STANDARD seul, une URL de redirection exacte. Le CRD KeycloakOIDCClient est inutilisable, cf. ci-dessus
values.yaml Dex : issuer public, stockage Kubernetes, connecteur oidc vers le realm, client statique kubernetes ; les deux secrets viennent de variables d'environnement
httproute.yaml HTTPRoute HTTPS dex.lab.example.iodex:5556, sectionName: https
rbac.yaml oidc:k8s-adminscluster-admin, oidc:k8s-viewersview
apiserver-oidc.talos.yaml patch de configuration machine Talos (talosctl patch mc)
apiserver-oidc.kubeadm.yaml fragment de ClusterConfiguration à fusionner dans kubeadm-config
dex-up.sh l'installation tout-en-un (idempotente), hors modification du control plane

✅ Vérifier#

kubectl -n dex get pods,httproute                       # dex Running, route Accepted
kubectl -n keycloak exec -i keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get clients -r lab -q clientId=dex --fields clientId,enabled,redirectUris   # le client existe
curl -sk https://dex.lab.example.io/.well-known/openid-configuration | jq .issuer

# l'apiserver voit réellement les flags (Talos comme kubeadm) :
kubectl -n kube-system get pod -l component=kube-apiserver \
  -o jsonpath='{.items[0].spec.containers[0].command}' | tr ',' '\n' | grep oidc

# toute la chaîne, en une commande :
kubectl --context=oidc auth whoami                      # oidc:demo@lab.example.io, oidc:k8s-admins
kubectl --context=oidc auth can-i '*' '*' --all-namespaces   # yes

🧪 Les deux comptes — même login, deux jeux de droits#

Le realm livre deux utilisateurs, et c'est le second qui rend la démonstration réfutable : si les deux pouvaient faire la même chose, rien ne prouverait que le claim groups est réellement lu.

Utilisateur Groupe ClusterRole Mot de passe
demo k8s-admins cluster-admin kubectl -n keycloak get secret keycloak-demo-user -o jsonpath='{.data.password}' | base64 -d ; echo
viewer k8s-viewers view kubectl -n keycloak get secret keycloak-viewer-user -o jsonpath='{.data.password}' | base64 -d ; echo

Aucune ligne de rbac.yaml ne nomme une personne : les sujets sont des groupes (kind: Group, oidc:k8s-admins / oidc:k8s-viewers). Ajouter un collègue est une appartenance de groupe dans Keycloak, jamais une commande kubectl.

Un second contexte, pour que les deux coexistent — même cluster, même issuer, autre compte. La séparation du cache n'est pas un détail : sans --token-cache-dir distinct, kubelogin réutilise le token de demo en cache et le contexte « viewer » reste silencieusement admin.

kubectl config set-credentials oidc-viewer \
  --exec-api-version=client.authentication.k8s.io/v1beta1 \
  --exec-command=kubectl \
  --exec-arg=oidc-login --exec-arg=get-token \
  --exec-arg=--oidc-issuer-url=https://dex.lab.example.io \
  --exec-arg=--oidc-client-id=kubernetes \
  --exec-arg=--oidc-client-secret="$(kubectl -n dex get secret dex-kubernetes-client -o jsonpath='{.data.client-secret}' | base64 -d)" \
  --exec-arg=--oidc-extra-scope=groups --exec-arg=--oidc-extra-scope=email \
  --exec-arg=--token-cache-dir=~/.kube/cache/oidc-login-viewer
kubectl config set-context oidc-viewer --cluster=<ton-cluster> --user=oidc-viewer

Le contrôle qui compte est celui qui doit échouer :

kubectl --context=oidc-viewer auth whoami            # oidc:viewer@lab.example.io, oidc:k8s-viewers
kubectl --context=oidc-viewer auth can-i list pods   # yes
kubectl --context=oidc-viewer auth can-i create pods # no
kubectl --context=oidc-viewer auth can-i get secrets # no  <- `view` exclut les Secrets

⚠️ view n'est pas « tout lire ». Il n'accorde rien sur les objets à portée cluster : kubectl --context=oidc-viewer get nodes est donc Forbidden — c'est le ClusterRole qui fonctionne, pas le login qui casse. Teste avec get pods, que view autorise bien.

Basculer d'un compte à l'autre#

Deux caches te maintiennent connecté, et c'est au second que personne ne pense.

# 1. le cache de token de kubelogin — par contexte, d'où le --token-cache-dir distinct ci-dessus
rm -rf ~/.kube/cache/oidc-login ~/.kube/cache/oidc-login-viewer

# 2. la session SSO Keycloak — sans ça, le navigateur s'ouvre et se referme aussitôt, et tu es
#    silencieusement reconnecté avec l'utilisateur PRÉCÉDENT. Rien ne le signale.
kubectl -n keycloak exec -i keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  config credentials --server http://localhost:8080 --realm master \
  --user "$(kubectl -n keycloak get secret keycloak-initial-admin -o jsonpath='{.data.username}' | base64 -d)" \
  --password "$(kubectl -n keycloak get secret keycloak-initial-admin -o jsonpath='{.data.password}' | base64 -d)"
kubectl -n keycloak exec -i keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  create realms/lab/logout-all                       # ferme toutes les sessions du realm

Une fenêtre de navigation privée obtient le même résultat sans toucher à aucun état, et c'est le plus rapide pour tenir les deux identités en parallèle.

⚠️ Un logout ne révoque pas un token déjà émis. L'apiserver valide une signature et une expiration, pas un état de session : après logout-all, un token présent dans le cache de kubelogin reste utilisable jusqu'à son expiration. Pour réellement couper l'accès d'un compte, désactive l'utilisateur (enabled: false) — ou vide le cache local. logout-all empêche seulement le PROCHAIN login d'être silencieux.

💡 Un auth whoami qui renvoie l'utilisateur précédent est le symptôme d'une session SSO vivante, pas d'un contexte mal configuré. C'est le contrôle qui distingue les deux.

🧪 Scénario — l'autorisation vit dans Keycloak, pas dans le cluster#

# 1. demo est dans k8s-admins : tous les droits
kubectl --context=oidc auth can-i delete nodes                  # yes

# 2. Dans la console Keycloak (https://keycloak.<LAB_DOMAIN>/admin/), realm `lab` :
#    Users -> demo -> Groups -> quitter k8s-admins, rejoindre k8s-viewers

# 3. Forcer un jeton neuf — l'ancien est en cache et reste valide jusqu'à expiration
rm -rf ~/.kube/cache/oidc-login
kubectl --context=oidc auth whoami                              # oidc:k8s-viewers
kubectl --context=oidc auth can-i delete nodes                  # no
kubectl --context=oidc get pods -A                              # marche encore : `view`

Pas une commande kubectl n'a été nécessaire pour changer ces droits, et rien n'a été révoqué dans le cluster. C'est tout l'intérêt — et le cache est tout le bémol : un jeton déjà émis garde ses groupes jusqu'à son expiration. Le déprovisionnement OIDC n'est jamais instantané.

🚑 Dépannage#

  • oidc: id token issued by a different provider → les trois URL ne coïncident pas. Comparer config.issuer (kubectl -n dex get secret dex -o jsonpath='{.data.config\.yaml}' | base64 -d), le hostname de l'HTTPRoute et --oidc-issuer-url de l'apiserver. Un / final suffit.
  • L'apiserver ne redémarre plus après le patch → presque toujours l'émetteur : injoignable, ou certificat non trusté. Sur Talos, talosctl -n <ip> logs kubelet ; sur kubeadm, sudo crictl logs $(sudo crictl ps -a --name kube-apiserver -q | head -1). Défaire les flags.
  • La connexion marche mais tout est Forbidden → les groupes manquent dans le jeton. Vérifier dans l'ordre : le scope groups demandé par Dex (values.yaml), insecureEnableGroups: true sur le connecteur, le client scope groups du realm (../keycloak/), et le préfixe oidc: de rbac.yaml.
  • kubectl auth whoami affiche le bon utilisateur mais aucun groupe → mêmes causes, et vérifier que le client Keycloak a bien reçu le scope groups : le realm l'attribue aux nouveaux clients (defaultDefaultClientScopes) ; un client créé avant ce réglage ne l'a pas.
  • Le navigateur ne revient jamais / redirect_uri_mismatch → kubelogin écoute sur :8000. L'URI doit figurer dans staticClients[].redirectURIs (Dex) ; côté Keycloak, le client ne liste que https://dex.<LAB_DOMAIN>/callback, qui est le callback de Dex, pas celui de kubectl.
  • x509: certificate signed by unknown authoritySELF_SIGNED=true et quelqu'un dans la chaîne n'a pas l'AC du lab : l'apiserver (oidc-ca-file), kubelogin (--certificate-authority), ou Dex quand il appelle Keycloak.
  • Le pod Dex redémarre en bouclekubectl -n dex logs deploy/dex. Un Secret manquant, ou un $KEYCLOAK_CLIENT_SECRET non substitué, se voit comme un connecteur qui refuse de démarrer.

⚠️ Pièges#

  • cluster-admin est donné à un groupe : quiconque Keycloak y met devient admin du cluster. L'IdP fait désormais partie du périmètre de confiance du cluster ; qui administre le realm administre Kubernetes.
  • La révocation n'est pas immédiate. Un jeton déjà délivré reste valide jusqu'à son expiration, quoi qu'on change dans Keycloak. Kubernetes ne vérifie rien côté IdP à chaque requête.
  • Le préfixe oidc: est porteur. Sans lui, un annuaire qui déclare un groupe system:masters prend le cluster. Si tu le changes, change aussi rbac.yaml.
  • Ne jamais retirer les authentifieurs par certificat. OIDC s'y ajoute ; un cluster dont la seule porte est un IdP est un cluster où l'on ne rentre plus quand l'IdP est tombé — et ici, l'IdP tourne dans ce cluster.
  • insecureEnableGroups est mal nommé mais obligatoire : sans lui, le connecteur OIDC générique de Dex jette les groupes de l'amont, sans rien dire.
  • Les secrets de client sont générés une fois. Relancer le script ne les fait pas tourner, volontairement : régénérer celui de dex d'un seul côté casserait le client sans la moindre erreur visible. Les faire tourner délibérément — les deux namespaces, puis redémarrer Dex.
  • Éditer /etc/kubernetes/manifests/kube-apiserver.yaml à la main sur kubeadm tient jusqu'au prochain kubeadm upgrade, qui le régénère depuis kubeadm-config.

📚 Références#

🔒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.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 both labs (Talos has no swap; kubeadm requires swap off and kubeadm/provision.sh disables it) 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 *.lab.example.io the HTTPRoute has no dedicated Certificate kubectl -n envoy-gateway-system get certificate
DNS vault.lab.example.io → 192.168.56.200 reach the UI from the host getent hosts vault.lab.example.io
jq on the host slice up the JSON output of operator init jq --version

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

⚡ Install#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> vault     # <distro> = talos | kubeadm
./vault-cluster/vault-up.sh <distro>

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

🧬 Talos vs kubeadm#

No distribution-specific behaviour for this component: same charts, same manifests, same values on both labs. The distribution argument only drives two things here: the default domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env / kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).

ℹ️ disable_mlock=true is safe on both labs: Talos has no swap, and the kubeadm lab disables then masks it at provisioning time. The longhorn prerequisite is where the distribution differences live.

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. Check the storage prerequisite#

kubectl get sc longhorn      # 3 Raft PVCs: without it the pods stay Pending

2. The chart in HA mode (integrated Raft, 3 replicas)#

helm repo add hashicorp https://helm.releases.hashicorp.com && helm repo update hashicorp
helm upgrade --install vault hashicorp/vault -n vault --create-namespace \
  --version 0.34.0 \
  --values vault-cluster/values.yaml
kubectl -n vault get pods -w        # the 3 pods come up NOT READY: they are SEALED

3. Initialise — once for the lifetime of the cluster#

The unseal keys and root token are printed HERE and nowhere else. Lose them and Vault is unrecoverable.

kubectl -n vault exec vault-0 -- vault operator init \
  -key-shares=1 -key-threshold=1 -format=json > ../Vagrant-Talos/_out/vault-init.json
chmod 600 ../Vagrant-Talos/_out/vault-init.json
UNSEAL=$(jq -r '.unseal_keys_b64[0]' ../Vagrant-Talos/_out/vault-init.json)
ROOT=$(jq -r '.root_token'          ../Vagrant-Talos/_out/vault-init.json)

4. Unseal the leader, then join the other two to the Raft#

kubectl -n vault exec vault-0 -- vault operator unseal "$UNSEAL"
for p in vault-1 vault-2; do
  kubectl -n vault exec $p -- vault operator raft join http://vault-0.vault-internal:8200
  kubectl -n vault exec $p -- vault operator unseal "$UNSEAL"
done
kubectl -n vault exec vault-0 -- env VAULT_TOKEN="$ROOT" vault operator raft list-peers

⚠️ Repeat after every pod restart: a restarted Vault comes back SEALED. That is exactly what vault-up.sh redoes for you on every run.

5. Expose the UI/API over HTTPS#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" vault-cluster/httproute.yaml | kubectl apply -f -
curl --resolve "vault.${LAB_DOMAIN}:443:192.168.56.200" "https://vault.${LAB_DOMAIN}/v1/sys/health" -kS | head -c 200; echo

6. Verify the HA state#

kubectl -n vault get pods            # 3/3 Running, READY 1/1
kubectl -n vault exec vault-0 -- vault status | grep -E 'Sealed|HA Mode|Raft'
echo "UI: https://vault.${LAB_DOMAIN}  (token: \$ROOT)"

🔐 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, node reboot / vagrant halt) brings it back sealed: re-run ./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 vault-cluster/httproute.yaml

🌐 Domain: the manifest carries the neutral domain 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/lab\.example\.io/kubeadm.lab.my-domain.tld/g' \
  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.lab.example.io:443:192.168.56.200 \
  https://vault.lab.example.io/ui/                              # 200

🌐 Access#

What Value / command
UI (public HTTPS) https://vault.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#

🔒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.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 les deux labs (Talos n'a pas de swap ; kubeadm exige le swap coupé et kubeadm/provision.sh le coupe) 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 *.lab.example.io le HTTPRoute n'a pas de Certificate dédié kubectl -n envoy-gateway-system get certificate
DNS vault.lab.example.io → 192.168.56.200 joindre l'UI depuis l'hôte getent hosts vault.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#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> vault     # <distro> = talos | kubeadm
./vault-cluster/vault-up.sh <distro>

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

🧬 Talos vs kubeadm#

Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes, mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou ../Vagrant-KubeADM/).

ℹ️ disable_mlock=true est sûr sur les deux labs : Talos n'a pas de swap, et le lab kubeadm le coupe puis le masque au provisioning. Le prérequis longhorn porte, lui, les différences de distribution.

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Vérifier le prérequis stockage#

kubectl get sc longhorn      # 3 PVC Raft : sans elle les pods restent Pending

2. Le chart en mode HA (Raft intégré, 3 réplicas)#

helm repo add hashicorp https://helm.releases.hashicorp.com && helm repo update hashicorp
helm upgrade --install vault hashicorp/vault -n vault --create-namespace \
  --version 0.34.0 \
  --values vault-cluster/values.yaml
kubectl -n vault get pods -w        # les 3 pods montent, NOT READY : ils sont SCELLÉS

3. Initialiser — une seule fois pour la vie du cluster#

Les clés de descellement et le root token ne sont donnés qu'ICI. Perdus = Vault irrécupérable.

kubectl -n vault exec vault-0 -- vault operator init \
  -key-shares=1 -key-threshold=1 -format=json > ../Vagrant-Talos/_out/vault-init.json
chmod 600 ../Vagrant-Talos/_out/vault-init.json
UNSEAL=$(jq -r '.unseal_keys_b64[0]' ../Vagrant-Talos/_out/vault-init.json)
ROOT=$(jq -r '.root_token'          ../Vagrant-Talos/_out/vault-init.json)

4. Desceller le leader, puis joindre les deux autres au Raft#

kubectl -n vault exec vault-0 -- vault operator unseal "$UNSEAL"
for p in vault-1 vault-2; do
  kubectl -n vault exec $p -- vault operator raft join http://vault-0.vault-internal:8200
  kubectl -n vault exec $p -- vault operator unseal "$UNSEAL"
done
kubectl -n vault exec vault-0 -- env VAULT_TOKEN="$ROOT" vault operator raft list-peers

⚠️ À REFAIRE après chaque redémarrage de pod : un Vault qui redémarre repart SCELLÉ. C'est exactement ce que vault-up.sh refait pour toi à chaque passage.

5. Exposer l'UI/API en HTTPS#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" vault-cluster/httproute.yaml | kubectl apply -f -
curl --resolve "vault.${LAB_DOMAIN}:443:192.168.56.200" "https://vault.${LAB_DOMAIN}/v1/sys/health" -kS | head -c 200; echo

6. Vérifier l'état HA#

kubectl -n vault get pods            # 3/3 Running, READY 1/1
kubectl -n vault exec vault-0 -- vault status | grep -E 'Sealed|HA Mode|Raft'
echo "UI : https://vault.${LAB_DOMAIN}  (token : \$ROOT)"

🔐 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 du node / vagrant halt) le fait revenir scellé : relancer ./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 vault-cluster/httproute.yaml

🌐 Domaine : le manifeste porte le domaine neutre 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/lab\.example\.io/kubeadm.lab.mon-domaine.tld/g' \
  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.lab.example.io:443:192.168.56.200 \
  https://vault.lab.example.io/ui/                              # 200

🌐 Accès#

Quoi Valeur / commande
UI (HTTPS public) https://vault.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#

🔑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 (keepalived on kubeadm, Talos VIP otherwise) 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#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

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.lab.example.io
export VAULT_TOKEN=<root-token>                                  # see ../vault-cluster/README.md
./vault-secret-operator/vault/lab-kv.sh

# 2. The operator (chart pinned to 1.5.0)
./install.sh <distro> vso                # or ./vault-secret-operator/vso-up.sh <distro>

# 3. The CRs on the cluster side                                    -> see k8s/README.md
kubectl apply -f vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml

Chart 1.5.0 = app version 1.5.0.

ℹ️ vso-up.sh installs step 2 and nothing else, on purpose. Step 1 needs an admin VAULT_TOKEN — a credential that has no business in a script install.sh all runs in a loop — and step 3 is inert until step 1 has created the matching role: VSO connects, Vault refuses, and the Secret is never filled. The operator alone is harmless: it waits for CRs.

What vso-up.sh runs, in full
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 vault-secret-operator/values.yaml
kubectl -n vault-secrets-operator rollout status deploy/vault-secrets-operator-controller-manager

🧬 Talos vs kubeadm#

One difference only, and it is cosmetic by choice: the name of the demo KV-v2 mount, so both labs can coexist inside a single Vault.

Talos kubeadm
KV-v2 mount (VAULT_KV_MOUNT) talos-lab/ kubeadm-lab/
Derived policy talos-lab-nginx-test-vault kubeadm-lab-nginx-test-vault

Versioned files carry the NEUTRAL marker lab-kv; it is substituted on the fly, exactly like the domain (the render helper in lib/common.sh, and a sed in vault/lab-kv.sh). Everything else — VSO, VaultConnection, VaultAuth, the vso-* policies, PostgreSQL rotation, PKI — is identical on both distributions.

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. Prerequisite: an unsealed Vault#

kubectl -n vault exec vault-0 -- vault status | grep -E 'Sealed|HA Mode'
export VAULT_ADDR="https://vault.${LAB_DOMAIN}"     # or http://127.0.0.1:8200 via port-forward
export VAULT_TOKEN=$(jq -r .root_token ../Vagrant-Talos/_out/vault-init.json)

2. VAULT side: engines, Kubernetes auth, policies and roles#

./vault-secret-operator/vault/00-secrets-engines.sh <distro>   # kvv2, database, pki, transit
./vault-secret-operator/vault/01-kubernetes-auth.sh  <distro>  # auth/kubernetes (in-cluster mode)
./vault-secret-operator/vault/02-roles.sh            <distro>  # vso-* policies + roles
./vault-secret-operator/vault/lab-kv.sh              <distro>  # <distro>-lab/ mount + nginx demo
vault secrets list ; vault auth list ; vault policy list

3. KUBERNETES side: the operator#

helm repo add hashicorp https://helm.releases.hashicorp.com && helm repo update hashicorp
helm upgrade --install vault-secrets-operator hashicorp/vault-secrets-operator \
  -n vault-secrets-operator --create-namespace \
  --version 1.5.0 --values vault-secret-operator/values.yaml
kubectl -n vault-secrets-operator rollout status deploy/vault-secrets-operator-controller-manager --timeout=180s
kubectl get crd | grep secrets.hashicorp.com

4. The base CRs: connection + authentication#

kubectl apply -f vault-secret-operator/k8s/00-namespace-rbac.yaml
kubectl apply -f vault-secret-operator/k8s/01-vaultconnection.yaml
kubectl apply -f vault-secret-operator/k8s/02-vaultauth.yaml
kubectl apply -f vault-secret-operator/k8s/03-vaultauthglobal.yaml

5. The three synchronisation types, one at a time#

kubectl apply -f vault-secret-operator/k8s/10-static-kv.yaml    # static KV-v2
kubectl apply -f vault-secret-operator/k8s/20-dynamic-db.yaml    # ephemeral DB creds
kubectl apply -f vault-secret-operator/k8s/30-pki-tls.yaml       # PKI certificate
kubectl -n demo get secrets
kubectl -n demo get vaultstaticsecret,vaultdynamicsecret,vaultpkisecret

6. The end-to-end demo (mount <distro>-lab)#

sed "s/lab-kv/talos-lab/g" vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml \
  | kubectl apply -f -                       # or kubeadm-lab, per distribution
kubectl -n nginx-test-vault get secret nginx-test-vault-config -o jsonpath='{.data.APP_GREETING}' | base64 -d; echo

7. Watch the rotation#

# change the value in Vault…
vault kv put talos-lab/nginx-test-vault/config APP_GREETING="New value" APP_COLOR=red APP_SECRET_TOKEN=s3cr3t-v2
# …the K8s Secret follows on its own (refreshAfter), and the pod is restarted when rolloutRestartTargets is set
kubectl -n nginx-test-vault get secret nginx-test-vault-config -o jsonpath='{.data.APP_GREETING}' | base64 -d; echo
kubectl -n vault-secrets-operator logs deploy/vault-secrets-operator-controller-manager --tail=20

🔧 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 lab-kv/ with one subdirectory per app, and an nginx demo that proves the whole loop.

./vault-secret-operator/vault/lab-kv.sh                     # Vault config
kubectl apply -f vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml

# Rotation, live: change the value in Vault…
vault kv put lab-kv/nginx-test-vault/config \
  APP_GREETING="Hello from 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)
./vault-secret-operator/vault/pg-dynamic-rotate.sh
kubectl apply -f 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#

🔑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 (keepalived sur kubeadm, VIP Talos sinon) 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#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

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.lab.example.io
export VAULT_TOKEN=<root-token>                                  # cf. ../vault-cluster/LISEZ-MOI.md
./vault-secret-operator/vault/lab-kv.sh

# 2. L'opérateur (chart épinglé en 1.5.0)
./install.sh <distro> vso                # ou ./vault-secret-operator/vso-up.sh <distro>

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

Chart 1.5.0 = app version 1.5.0.

ℹ️ vso-up.sh pose l'étape 2 et rien d'autre, volontairement. L'étape 1 exige un VAULT_TOKEN d'admin — un secret qui n'a rien à faire dans un script que install.sh all enchaîne en boucle — et l'étape 3 est inerte tant que l'étape 1 n'a pas créé le role correspondant : VSO se connecte, Vault refuse, et le Secret n'est jamais rempli. L'opérateur seul est inoffensif : il attend des CR.

Ce que lance vso-up.sh, en entier
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 vault-secret-operator/values.yaml
kubectl -n vault-secrets-operator rollout status deploy/vault-secrets-operator-controller-manager

🧬 Talos vs kubeadm#

Une seule différence, et elle est cosmétique par choix : le nom du moteur KV-v2 de démonstration, pour que les deux labs puissent coexister dans un même Vault.

Talos kubeadm
Moteur KV-v2 (VAULT_KV_MOUNT) talos-lab/ kubeadm-lab/
Policy dérivée talos-lab-nginx-test-vault kubeadm-lab-nginx-test-vault

Les fichiers versionnés portent le marqueur NEUTRE lab-kv ; il est substitué à la volée, exactement comme le domaine (fonction render de lib/common.sh, et sed dans vault/lab-kv.sh). Tout le reste — VSO, VaultConnection, VaultAuth, policies vso-*, rotation PostgreSQL, PKI — est identique sur les deux distributions.

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Prérequis : un Vault descellé#

kubectl -n vault exec vault-0 -- vault status | grep -E 'Sealed|HA Mode'
export VAULT_ADDR="https://vault.${LAB_DOMAIN}"     # ou http://127.0.0.1:8200 en port-forward
export VAULT_TOKEN=$(jq -r .root_token ../Vagrant-Talos/_out/vault-init.json)

2. Côté VAULT : moteurs, auth Kubernetes, policies et roles#

./vault-secret-operator/vault/00-secrets-engines.sh <distro>   # kvv2, database, pki, transit
./vault-secret-operator/vault/01-kubernetes-auth.sh  <distro>  # auth/kubernetes (mode in-cluster)
./vault-secret-operator/vault/02-roles.sh            <distro>  # policies vso-* + roles
./vault-secret-operator/vault/lab-kv.sh              <distro>  # moteur <distro>-lab/ + démo nginx
vault secrets list ; vault auth list ; vault policy list

3. Côté KUBERNETES : l'opérateur#

helm repo add hashicorp https://helm.releases.hashicorp.com && helm repo update hashicorp
helm upgrade --install vault-secrets-operator hashicorp/vault-secrets-operator \
  -n vault-secrets-operator --create-namespace \
  --version 1.5.0 --values vault-secret-operator/values.yaml
kubectl -n vault-secrets-operator rollout status deploy/vault-secrets-operator-controller-manager --timeout=180s
kubectl get crd | grep secrets.hashicorp.com

4. Les CR de base : connexion + authentification#

kubectl apply -f vault-secret-operator/k8s/00-namespace-rbac.yaml
kubectl apply -f vault-secret-operator/k8s/01-vaultconnection.yaml
kubectl apply -f vault-secret-operator/k8s/02-vaultauth.yaml
kubectl apply -f vault-secret-operator/k8s/03-vaultauthglobal.yaml

5. Les trois types de synchronisation, une par une#

kubectl apply -f vault-secret-operator/k8s/10-static-kv.yaml    # KV-v2 statique
kubectl apply -f vault-secret-operator/k8s/20-dynamic-db.yaml    # creds DB éphémères
kubectl apply -f vault-secret-operator/k8s/30-pki-tls.yaml       # certificat PKI
kubectl -n demo get secrets
kubectl -n demo get vaultstaticsecret,vaultdynamicsecret,vaultpkisecret

6. La démo de bout en bout (mount <distro>-lab)#

sed "s/lab-kv/talos-lab/g" vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml \
  | kubectl apply -f -                       # ou kubeadm-lab selon la distro
kubectl -n nginx-test-vault get secret nginx-test-vault-config -o jsonpath='{.data.APP_GREETING}' | base64 -d; echo

7. Observer la rotation#

# on change la valeur dans Vault…
vault kv put talos-lab/nginx-test-vault/config APP_GREETING="Nouvelle valeur" APP_COLOR=red APP_SECRET_TOKEN=s3cr3t-v2
# …le Secret K8s suit tout seul (refreshAfter), et le pod est redémarré si rolloutRestartTargets est posé
kubectl -n nginx-test-vault get secret nginx-test-vault-config -o jsonpath='{.data.APP_GREETING}' | base64 -d; echo
kubectl -n vault-secrets-operator logs deploy/vault-secrets-operator-controller-manager --tail=20

🔧 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 lab-kv/ avec un sous-dossier par appli, et une démo nginx qui prouve la boucle complète.

./vault-secret-operator/vault/lab-kv.sh                     # config Vault
kubectl apply -f vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml

# La rotation, en direct : on change la valeur dans Vault…
vault kv put lab-kv/nginx-test-vault/config \
  APP_GREETING="Hello from 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)
./vault-secret-operator/vault/pg-dynamic-rotate.sh
kubectl apply -f 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#

🧾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#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Path A — the lab demos (tested)#

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

kubectl apply -f vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml
kubectl apply -f 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.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/lab\.example\.io/kubeadm.lab.my-domain.tld/g' 30-pki-tls.yaml | kubectl apply -f - (see ../../README.md).

🧬 Talos vs kubeadm#

One difference only, and it is cosmetic by choice: the name of the demo KV-v2 mount, so both labs can coexist inside a single Vault.

Talos kubeadm
KV-v2 mount (VAULT_KV_MOUNT) talos-lab/ kubeadm-lab/
Derived policy talos-lab-nginx-test-vault kubeadm-lab-nginx-test-vault

Versioned files carry the NEUTRAL marker lab-kv; it is substituted on the fly, exactly like the domain (the render helper in lib/common.sh, and a sed in vault/lab-kv.sh). Everything else — VSO, VaultConnection, VaultAuth, the vso-* policies, PostgreSQL rotation, PKI — is identical on both distributions.

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

These manifests are applied by hand, so they do NOT get automatic substitution. Two markers to replace for your lab: lab.example.io (domain) and lab-kv (KV mount).

1. Namespace + ServiceAccount + RBAC#

kubectl apply -f 00-namespace-rbac.yaml
kubectl -n demo get sa vso-app

2. The Vault connection (in-cluster)#

kubectl apply -f 01-vaultconnection.yaml
kubectl -n vault-secrets-operator get vaultconnection -o yaml | grep address

3. Authentication (role + vault audience)#

The audience MUST match the one on the Vault role, otherwise the login fails with a 403.

kubectl apply -f 02-vaultauth.yaml
kubectl apply -f 03-vaultauthglobal.yaml
kubectl -n demo describe vaultauth | tail -15

4. A STATIC secret (KV-v2)#

sed 's/lab-kv/talos-lab/g' 10-static-kv.yaml | kubectl apply -f -
kubectl -n demo get vaultstaticsecret
kubectl -n demo get secret static-kv-demo -o jsonpath='{.data}'; echo

5. A DYNAMIC secret (database)#

kubectl apply -f 20-dynamic-db.yaml
kubectl -n demo get vaultdynamicsecret
kubectl -n demo get secret dynamic-db-demo -o jsonpath='{.data.username}' | base64 -d; echo
# read it again in a few minutes: the user has CHANGED (ephemeral creds)

6. A PKI certificate#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" 30-pki-tls.yaml | kubectl apply -f -
kubectl -n demo get vaultpkisecret
kubectl -n demo get secret pki-tls-demo -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -subject -dates

7. Secret transformation + demo application#

kubectl apply -f 40-secrettransformation.yaml     # rename/reshape the keys
kubectl apply -f 50-demo-deployment.yaml          # an app consuming the Secret
kubectl -n demo logs deploy/vso-demo --tail=10

🔧 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.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.38: 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: lab-kv, path: nginx-test-vault/config, refreshAfter: 30s, hmacSecretData: true (detects drift without logging the values), rolloutRestartTargets → the Deployment
Deployment nginx-test-vault nginx:1.30-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.23, 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#

🧾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#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

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

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

kubectl apply -f vault-secret-operator/k8s/nginx-test-vault/nginx-test-vault.yaml
kubectl apply -f 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.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/lab\.example\.io/kubeadm.lab.mon-domaine.tld/g' 30-pki-tls.yaml | kubectl apply -f - (cf. ../../LISEZ-MOI.md).

🧬 Talos vs kubeadm#

Une seule différence, et elle est cosmétique par choix : le nom du moteur KV-v2 de démonstration, pour que les deux labs puissent coexister dans un même Vault.

Talos kubeadm
Moteur KV-v2 (VAULT_KV_MOUNT) talos-lab/ kubeadm-lab/
Policy dérivée talos-lab-nginx-test-vault kubeadm-lab-nginx-test-vault

Les fichiers versionnés portent le marqueur NEUTRE lab-kv ; il est substitué à la volée, exactement comme le domaine (fonction render de lib/common.sh, et sed dans vault/lab-kv.sh). Tout le reste — VSO, VaultConnection, VaultAuth, policies vso-*, rotation PostgreSQL, PKI — est identique sur les deux distributions.

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

Ces manifestes s'appliquent à la main : ils ne bénéficient donc PAS de la substitution automatique. Deux marqueurs à remplacer selon ton lab : lab.example.io (domaine) et lab-kv (moteur KV).

1. Namespace + ServiceAccount + RBAC#

kubectl apply -f 00-namespace-rbac.yaml
kubectl -n demo get sa vso-app

2. La connexion à Vault (in-cluster)#

kubectl apply -f 01-vaultconnection.yaml
kubectl -n vault-secrets-operator get vaultconnection -o yaml | grep address

3. L'authentification (role + audience « vault »)#

L'audience DOIT correspondre à celle du role Vault, sinon le login échoue en 403.

kubectl apply -f 02-vaultauth.yaml
kubectl apply -f 03-vaultauthglobal.yaml
kubectl -n demo describe vaultauth | tail -15

4. Un secret STATIQUE (KV-v2)#

sed 's/lab-kv/talos-lab/g' 10-static-kv.yaml | kubectl apply -f -
kubectl -n demo get vaultstaticsecret
kubectl -n demo get secret static-kv-demo -o jsonpath='{.data}'; echo

5. Un secret DYNAMIQUE (base de données)#

kubectl apply -f 20-dynamic-db.yaml
kubectl -n demo get vaultdynamicsecret
kubectl -n demo get secret dynamic-db-demo -o jsonpath='{.data.username}' | base64 -d; echo
# relire dans quelques minutes : l'utilisateur a CHANGÉ (creds éphémères)

6. Un certificat PKI#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" 30-pki-tls.yaml | kubectl apply -f -
kubectl -n demo get vaultpkisecret
kubectl -n demo get secret pki-tls-demo -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -subject -dates

7. Transformation de secret + application de démo#

kubectl apply -f 40-secrettransformation.yaml     # renommer/reformater les clés
kubectl apply -f 50-demo-deployment.yaml          # une app qui consomme le Secret
kubectl -n demo logs deploy/vso-demo --tail=10

🔧 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.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.38 : 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: lab-kv, 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.30-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.23, 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#

⚙️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.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#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

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

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

This is the path that actually runs: KV-v2 mount lab-kv/ (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 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.

🧬 Talos vs kubeadm#

One difference only, and it is cosmetic by choice: the name of the demo KV-v2 mount, so both labs can coexist inside a single Vault.

Talos kubeadm
KV-v2 mount (VAULT_KV_MOUNT) talos-lab/ kubeadm-lab/
Derived policy talos-lab-nginx-test-vault kubeadm-lab-nginx-test-vault

Versioned files carry the NEUTRAL marker lab-kv; it is substituted on the fly, exactly like the domain (the render helper in lib/common.sh, and a sed in vault/lab-kv.sh). Everything else — VSO, VaultConnection, VaultAuth, the vso-* policies, PostgreSQL rotation, PKI — is identical on both distributions.

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

0. The environment (the scripts refuse to run without it)#

export VAULT_ADDR="https://vault.${LAB_DOMAIN}"      # or: kubectl -n vault port-forward svc/vault-active 8200:8200
export VAULT_TOKEN=$(jq -r .root_token ../Vagrant-Talos/_out/vault-init.json)
vault status

1. The secret engines#

./00-secrets-engines.sh <distro>
# manual equivalent:
vault secrets enable -path=kvv2 -version=2 kv
vault secrets enable database
vault secrets enable pki
vault secrets enable transit
vault secrets list

2. Kubernetes authentication (in-cluster mode)#

Vault runs INSIDE the cluster: it validates SA tokens with its own delegator SA, without token_reviewer_jwt or kubernetes_ca_cert.

./01-kubernetes-auth.sh <distro>
# manual equivalent:
vault auth enable kubernetes
vault write auth/kubernetes/config kubernetes_host="https://kubernetes.default.svc"
vault read auth/kubernetes/config

3. Policies and roles (least privilege)#

./02-roles.sh <distro>
vault policy list
vault read auth/kubernetes/role/vso-static

4. The lab mount + the demo secret#

The mount name depends on the distribution (talos-lab/ or kubeadm-lab/): versioned files carry lab-kv, which the script substitutes.

./lab-kv.sh <distro>
vault kv get talos-lab/nginx-test-vault/config          # or kubeadm-lab/…
vault read auth/kubernetes/role/nginx-test-vault

5. (Optional) PostgreSQL password rotation#

./pg-dynamic-rotate.sh <distro>
vault read database/static-creds/vault-rotate            # the password rotates, the user does not

🔧 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 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). No issuer= is set: disable_iss_validation stays true (the default since Vault 1.9), so the token's iss claim is never compared — which is what you want here, since kubeadm mints ServiceAccount tokens with the issuer https://kubernetes.default.svc.cluster.local while kubernetes_host is https://kubernetes.default.svc.

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, carried by keepalived on kubeadm, by Talos otherwise) 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. ⚠️ Do not pass --audience to kubectl create token for the reviewer JWT: kubeadm leaves --api-audiences at its default (the issuer, and nothing else), so a token requested on another audience is rejected at authentication and Vault's TokenReview returns 401.

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.

lab-kv.sh — the lab engine#

Kubernetes auth (kubernetes_host=https://kubernetes.default.svc), KV-v2 engine lab-kv/, secret lab-kv/nginx-test-vault/config (3 APP_* keys), policy lab-kv-nginx-test-vault (read on lab-kv/data|metadata/nginx-test-vault/* only) and role nginx-test-vault bound to the SA/ns nginx-test-vault.

Adding an app = a lab-kv/<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/lab-kv-nginx-test-vault.hcl read on lab-kv/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/, lab-kv/
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 lab-kv/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" (lab-kv.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 lab-kv.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 lab-kv.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 lab-kv.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#

⚙️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.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#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

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

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

C'est le chemin qui tourne vraiment : mount KV-v2 lab-kv/ (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 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.

🧬 Talos vs kubeadm#

Une seule différence, et elle est cosmétique par choix : le nom du moteur KV-v2 de démonstration, pour que les deux labs puissent coexister dans un même Vault.

Talos kubeadm
Moteur KV-v2 (VAULT_KV_MOUNT) talos-lab/ kubeadm-lab/
Policy dérivée talos-lab-nginx-test-vault kubeadm-lab-nginx-test-vault

Les fichiers versionnés portent le marqueur NEUTRE lab-kv ; il est substitué à la volée, exactement comme le domaine (fonction render de lib/common.sh, et sed dans vault/lab-kv.sh). Tout le reste — VSO, VaultConnection, VaultAuth, policies vso-*, rotation PostgreSQL, PKI — est identique sur les deux distributions.

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

0. L'environnement (les scripts refusent de démarrer sans)#

export VAULT_ADDR="https://vault.${LAB_DOMAIN}"      # ou : kubectl -n vault port-forward svc/vault-active 8200:8200
export VAULT_TOKEN=$(jq -r .root_token ../Vagrant-Talos/_out/vault-init.json)
vault status

1. Les moteurs de secrets#

./00-secrets-engines.sh <distro>
# équivalent manuel :
vault secrets enable -path=kvv2 -version=2 kv
vault secrets enable database
vault secrets enable pki
vault secrets enable transit
vault secrets list

2. L'authentification Kubernetes (mode in-cluster)#

Vault tourne DANS le cluster : il valide les tokens de SA avec son propre SA délégateur, sans token_reviewer_jwt ni kubernetes_ca_cert.

./01-kubernetes-auth.sh <distro>
# équivalent manuel :
vault auth enable kubernetes
vault write auth/kubernetes/config kubernetes_host="https://kubernetes.default.svc"
vault read auth/kubernetes/config

3. Les policies et les roles (moindre privilège)#

./02-roles.sh <distro>
vault policy list
vault read auth/kubernetes/role/vso-static

4. Le moteur du lab + le secret de démonstration#

Le nom du moteur dépend de la distribution (talos-lab/ ou kubeadm-lab/) : les fichiers versionnés portent lab-kv, substitué par le script.

./lab-kv.sh <distro>
vault kv get talos-lab/nginx-test-vault/config          # ou kubeadm-lab/…
vault read auth/kubernetes/role/nginx-test-vault

5. (Optionnel) La rotation de mot de passe PostgreSQL#

./pg-dynamic-rotate.sh <distro>
vault read database/static-creds/vault-rotate            # le password change, l'user non

🔧 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 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). Aucun issuer= n'est posé : disable_iss_validation reste à true (défaut ≥ Vault 1.9), la revendication iss du token n'est donc jamais comparée — c'est ce qu'on veut ici, kubeadm émettant les tokens de ServiceAccount avec l'issuer https://kubernetes.default.svc.cluster.local alors que kubernetes_host vaut https://kubernetes.default.svc.

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, portée par keepalived sur kubeadm, par Talos sinon) 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. ⚠️ Ne pas passer --audience à kubectl create token pour le JWT du reviewer : kubeadm laisse --api-audiences à son défaut (l'issuer, et rien d'autre), un token demandé sur une autre audience est donc rejeté à l'authentification et le TokenReview de Vault renvoie 401.

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.

lab-kv.sh — le moteur du lab#

Auth Kubernetes (kubernetes_host=https://kubernetes.default.svc), moteur KV-v2 lab-kv/, secret lab-kv/nginx-test-vault/config (3 clés APP_*), policy lab-kv-nginx-test-vault (lecture de lab-kv/data|metadata/nginx-test-vault/* seulement) et role nginx-test-vault bindé au SA/ns nginx-test-vault.

Ajouter une appli = un sous-dossier lab-kv/<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/lab-kv-nginx-test-vault.hcl read sur lab-kv/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/, lab-kv/
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 lab-kv/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" (lab-kv.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 lab-kv.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 à lab-kv.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 lab-kv.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#

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

🌐 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) vagrant ssh k8s-cp1 -c 'free -h'

⚠️ Control-plane RAM — the lab's lab.env.example ships CP_MEM=3072, which is NOT enough for this stack. Raise it to CP_MEM=4096 in the lab's lab.env before installing, then vagrant reload the CPs one at a time.

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. 2 GB already starve etcd on their own.

⚡ Install#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> observability     # <distro> = talos | kubeadm
kubectl apply -f longhorn/longhorn-r1-storageclass.yaml   # if not already done
./observability/observability-up.sh <distro>

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

Chart Version App
prometheus-community/kube-prometheus-stack 88.0.1 (KPS_VERSION) Prometheus Operator v0.93.0
grafana/loki 7.2.0 (LOKI_VERSION) Loki v3.6.11
grafana/alloy 1.11.0 (ALLOY_VERSION) Alloy v1.18.0

🧬 Talos vs kubeadm#

One difference, but it changes what Prometheus can see (KPS_SCRAPE_CONTROL_PLANE in the profiles):

Chart monitor Talos kubeadm Why
kubeControllerManager disabled enabled (:10257, HTTPS, insecureSkipVerify) kubeadm sets bind-address: 0.0.0.0 on the static pod; on Talos the component is not scrapable without dedicated TLS
kubeScheduler disabled enabled (:10259) same
kubeEtcd disabled enabled (:2381, scheme: http) the kubeadm lab passes listen-metrics-urls: http://0.0.0.0:2381 at bootstrap; by default that endpoint is loopback-only
kubeProxy disabled disabled either replaced by Cilium (eBPF), or metrics bound to 127.0.0.1:10249

The values file is shared (it encodes the kubeadm case): observability-up.sh adds the --set …enabled=false flags on Talos. Without that, Prometheus would show unexplained "down" targets — the worst possible outcome in a training session.

Alloy reads /var/log/pods: identical on both (containerd), with the monitoring namespace labelled privileged (required on Talos, intent documentation on kubeadm).

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. Prerequisites#

kubectl get sc longhorn-r1                  # Prometheus/Loki PVCs (1 block replica)
kubectl top nodes                           # metrics-server in place (platform)
free -g                                     # CP ≥ 4 GB: this stack is the hungriest

2. The monitoring namespace (privileged PodSecurity: node-exporter + Alloy)#

kubectl apply -f observability/namespace.yaml

3. kube-prometheus-stack — the --set flags differ per distribution#

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update prometheus-community
# values rendered with the domain (Grafana domain/root_url, externalUrl)
sed "s/lab\.example\.io/${LAB_DOMAIN}/g" observability/kube-prometheus-stack-values.yaml > /tmp/kps.yaml

# Talos: disable the control-plane monitors (not scrapable)
helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
  -n monitoring --create-namespace --version 88.0.1 --values /tmp/kps.yaml \
  --set kubeControllerManager.enabled=false \
  --set kubeScheduler.enabled=false \
  --set kubeEtcd.enabled=false

# kubeadm: keep the values as they are (all three monitors enabled)
helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
  -n monitoring --create-namespace --version 88.0.1 --values /tmp/kps.yaml

kubectl -n monitoring rollout status deploy/kube-prometheus-stack-grafana --timeout=300s

4. Loki (single binary, filesystem on Longhorn)#

helm repo add grafana https://grafana.github.io/helm-charts && helm repo update grafana
helm upgrade --install loki grafana/loki -n monitoring \
  --version 7.2.0 --values observability/loki-values.yaml
kubectl -n monitoring rollout status statefulset/loki --timeout=300s

5. Alloy (ships /var/log/pods → Loki)#

helm upgrade --install alloy grafana/alloy -n monitoring \
  --version 1.11.0 --values observability/alloy-values.yaml
kubectl -n monitoring rollout status daemonset/alloy --timeout=180s

6. The three HTTPRoutes#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" observability/httproutes.yaml | kubectl apply -f -

7. Verify — targets UP, and logs flowing#

# No "down" target: on Talos that is the whole point of step 3's --set flags
kubectl -n monitoring exec sts/prometheus-kube-prometheus-stack-prometheus -c prometheus -- \
  wget -qO- localhost:9090/api/v1/targets | tr ',' '\n' | grep -c '"health":"up"'
kubectl -n monitoring get servicemonitors
curl --resolve "grafana.${LAB_DOMAIN}:443:192.168.56.200" "https://grafana.${LAB_DOMAIN}/login" -kSI | head -1
echo "Grafana: https://grafana.${LAB_DOMAIN}  (admin / prom-operator — CHANGE IT)"

🔧 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); controller-manager & scheduler scraped, etcd & kube-proxy off (see below); 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 targets: two on, two off#

The Talos lab disabled all four control-plane monitors, because those components only listened on loopback there. On kubeadm the situation differs component by component, so they are enabled one by one — never a dead target:

Monitor State Why
kubeControllerManager on, :10257 HTTPS kubeadm/templates/kubeadm-init.yaml.tpl sets bind-address: 0.0.0.0 on it. insecureSkipVerify: true: the serving cert is signed by the cluster CA but carries no DNS name for the Service.
kubeScheduler on, :10259 HTTPS same thing.
kubeEtcd off etcd is a stacked static pod and does expose :2381, but kubeadm generates its manifest with --listen-metrics-urls=http://127.0.0.1:2381loopback only, it serves the pod's liveness probe. To open it for real: add listen-metrics-urls: http://0.0.0.0:2381 to etcd.local.extraArgs in kubeadm/templates/kubeadm-init.yaml.tpl, then flip kubeEtcd.enabled: true with service.port: 2381 and serviceMonitor.scheme: http.
kubeProxy off there is no kube-proxy: KUBE_PROXY_REPLACEMENT=true (the lab.env default) runs kubeadm init --skip-phases=addon/kube-proxy and Cilium handles Services in eBPF. Equivalent metrics come from Cilium.

Both static pods carry the component: kube-controller-manager / component: kube-scheduler labels that the chart's headless Service selects on: nothing else to wire up.

✅ Verify#

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

# Control-plane targets actually UP (one line per control plane, twice):
kubectl -n monitoring exec sts/prometheus-kube-prometheus-stack-prometheus -c prometheus -- \
  wget -qO- 'http://localhost:9090/api/v1/targets?state=active' \
  | grep -o '"job":"kube-[a-z-]*"' | sort -u   # kube-controller-manager, kube-scheduler

# 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.lab.example.io:443:192.168.56.200 https://$h.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.lab.example.io admin kubectl -n monitoring get secret kube-prometheus-stack-grafana -o jsonpath='{.data.admin-password}' | base64 -d; echo
Prometheus https://prometheus.lab.example.io no authentication
Alertmanager https://alertmanager.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/etcd) → 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. Unlike the Talos lab — where etcd was a Talos service, invisible to Loki — here etcd is a regular static pod: its logs do land in Loki ({pod=~"etcd-.*"}). Only its metrics endpoint stays out of reach (see "Control-plane targets" above).

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

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

🌐 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) vagrant ssh k8s-cp1 -c 'free -h'

⚠️ RAM des control-plane — le lab.env.example du lab livre CP_MEM=3072, ce qui NE SUFFIT PAS pour cette pile. Monte-le à CP_MEM=4096 dans le lab.env du lab avant d'installer, puis vagrant reload des CP un par un.

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. 2 Go affament déjà etcd tout seuls.

⚡ Installation#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> observability     # <distro> = talos | kubeadm
kubectl apply -f longhorn/longhorn-r1-storageclass.yaml   # si pas déjà fait
./observability/observability-up.sh <distro>

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

Chart Version App
prometheus-community/kube-prometheus-stack 88.0.1 (KPS_VERSION) Prometheus Operator v0.93.0
grafana/loki 7.2.0 (LOKI_VERSION) Loki v3.6.11
grafana/alloy 1.11.0 (ALLOY_VERSION) Alloy v1.18.0

🧬 Talos vs kubeadm#

Une seule différence, mais elle change ce que Prometheus voit (KPS_SCRAPE_CONTROL_PLANE dans les profils) :

Moniteur du chart Talos kubeadm Pourquoi
kubeControllerManager désactivé activé (:10257, HTTPS, insecureSkipVerify) kubeadm pose bind-address: 0.0.0.0 sur le pod statique ; sur Talos le composant n'est pas scrutable sans TLS dédié
kubeScheduler désactivé activé (:10259) idem
kubeEtcd désactivé activé (:2381, scheme: http) le lab kubeadm passe listen-metrics-urls: http://0.0.0.0:2381 au bootstrap ; par défaut l'endpoint n'existe qu'en loopback
kubeProxy désactivé désactivé soit remplacé par Cilium (eBPF), soit métriques en 127.0.0.1:10249

Le fichier de values est commun (il porte le cas kubeadm) : observability-up.sh ajoute les --set …enabled=false sur Talos. Sans ce réglage, Prometheus afficherait des cibles « down » inexplicables — le pire des scénarios en formation.

Alloy lit /var/log/pods : identique sur les deux (containerd), avec le namespace monitoring étiqueté privileged (indispensable sur Talos, documentation d'intention sur kubeadm).

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Prérequis#

kubectl get sc longhorn-r1                  # PVC Prometheus/Loki (1 réplica bloc)
kubectl top nodes                           # metrics-server en place (platform)
free -g                                     # CP ≥ 4 Go : cette pile est la plus gourmande

2. Namespace monitoring (PodSecurity privileged : node-exporter + Alloy)#

kubectl apply -f observability/namespace.yaml

3. kube-prometheus-stack — les --set diffèrent selon la distribution#

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update prometheus-community
# values rendues avec le domaine (Grafana domain/root_url, externalUrl)
sed "s/lab\.example\.io/${LAB_DOMAIN}/g" observability/kube-prometheus-stack-values.yaml > /tmp/kps.yaml

# Talos : on coupe les moniteurs du control plane (non scrutables)
helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
  -n monitoring --create-namespace --version 88.0.1 --values /tmp/kps.yaml \
  --set kubeControllerManager.enabled=false \
  --set kubeScheduler.enabled=false \
  --set kubeEtcd.enabled=false

# kubeadm : on garde les values telles quelles (les 3 moniteurs sont activés)
helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
  -n monitoring --create-namespace --version 88.0.1 --values /tmp/kps.yaml

kubectl -n monitoring rollout status deploy/kube-prometheus-stack-grafana --timeout=300s

4. Loki (single binary, filesystem sur Longhorn)#

helm repo add grafana https://grafana.github.io/helm-charts && helm repo update grafana
helm upgrade --install loki grafana/loki -n monitoring \
  --version 7.2.0 --values observability/loki-values.yaml
kubectl -n monitoring rollout status statefulset/loki --timeout=300s

5. Alloy (collecte des logs /var/log/pods → Loki)#

helm upgrade --install alloy grafana/alloy -n monitoring \
  --version 1.11.0 --values observability/alloy-values.yaml
kubectl -n monitoring rollout status daemonset/alloy --timeout=180s

6. Les trois HTTPRoutes#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" observability/httproutes.yaml | kubectl apply -f -

7. Vérifier — cibles UP, et logs qui arrivent#

# Aucune cible « down » : sur Talos c'est le sens des --set de l'étape 3
kubectl -n monitoring exec sts/prometheus-kube-prometheus-stack-prometheus -c prometheus -- \
  wget -qO- localhost:9090/api/v1/targets | tr ',' '\n' | grep -c '"health":"up"'
kubectl -n monitoring get servicemonitors
curl --resolve "grafana.${LAB_DOMAIN}:443:192.168.56.200" "https://grafana.${LAB_DOMAIN}/login" -kSI | head -1
echo "Grafana : https://grafana.${LAB_DOMAIN}  (admin / prom-operator — À CHANGER)"

🔧 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) ; controller-manager et scheduler scrutés, etcd et kube-proxy coupés (cf. ci-dessous) ; 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)

Cibles du control plane : deux allumées, deux éteintes#

Le lab Talos désactivait les quatre moniteurs du control plane, parce que ces composants n'y écoutaient qu'en loopback. Sur kubeadm la situation diffère composant par composant : on les active donc un par un, jamais en bloc — pas question de livrer une cible morte.

Moniteur État Pourquoi
kubeControllerManager on, :10257 HTTPS kubeadm/templates/kubeadm-init.yaml.tpl lui pose bind-address: 0.0.0.0. insecureSkipVerify: true : le certificat servi est signé par la CA du cluster mais ne porte pas le nom DNS du Service.
kubeScheduler on, :10259 HTTPS idem.
kubeEtcd off etcd est bien un pod statique empilé et expose bien :2381, mais kubeadm génère son manifeste avec --listen-metrics-urls=http://127.0.0.1:2381loopback uniquement, ça sert la probe de liveness du pod. Pour l'ouvrir vraiment : ajouter listen-metrics-urls: http://0.0.0.0:2381 à etcd.local.extraArgs dans kubeadm/templates/kubeadm-init.yaml.tpl, puis repasser kubeEtcd.enabled: true avec service.port: 2381 et serviceMonitor.scheme: http.
kubeProxy off il n'y a pas de kube-proxy : KUBE_PROXY_REPLACEMENT=true (défaut de lab.env) lance kubeadm init --skip-phases=addon/kube-proxy et Cilium prend les Services en eBPF. Les métriques équivalentes viennent de Cilium.

Les deux pods statiques portent les labels component: kube-controller-manager / component: kube-scheduler sur lesquels le Service headless du chart sélectionne : rien d'autre à câbler.

✅ Vérifier#

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

# Cibles du control plane réellement UP (une ligne par control plane, deux fois) :
kubectl -n monitoring exec sts/prometheus-kube-prometheus-stack-prometheus -c prometheus -- \
  wget -qO- 'http://localhost:9090/api/v1/targets?state=active' \
  | grep -o '"job":"kube-[a-z-]*"' | sort -u   # kube-controller-manager, kube-scheduler

# 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.lab.example.io:443:192.168.56.200 https://$h.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.lab.example.io admin kubectl -n monitoring get secret kube-prometheus-stack-grafana -o jsonpath='{.data.admin-password}' | base64 -d; echo
Prometheus https://prometheus.lab.example.io aucune authentification
Alertmanager https://alertmanager.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/etcd) → 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. Contrairement au lab Talos — où etcd était un service Talos, invisible pour Loki — ici etcd est un pod statique ordinaire : ses logs arrivent bien dans Loki ({pod=~"etcd-.*"}). Seul son endpoint de métriques reste hors de portée (cf. « Cibles du control plane » ci-dessus).

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

🩺node-problem-detector/ — node health (NodeConditions + Events)

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.

Lab adaptation (important)#

  • kernel-monitor only (/config/kernel-monitor.json, reading /dev/kmsg). The chart loads kernel-monitor + docker-monitor by default: the runtime here is containerd (kubeadm), there is no Docker socket → that monitor fails. systemd-monitor reads journald; systemd does exist on Debian 13, but Debian keeps the journal volatile (/run/log/journal) until /var/log/journal is created, while the chart only mounts /var/log → it would see nothing. So values.yaml reduces settings.log_monitors to kernel-monitor alone.
  • Namespace in PodSecurity privileged: NPD runs privileged (access to /dev/kmsg). kubeadm enforces no level cluster-wide by default, but the script labels the namespace anyway: it documents the intent and keeps working if admission is hardened.
  • NoSchedule/Exists tolerations → NPD runs on the control planes too (cp1/2/3).

⚡ Install#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> npd     # <distro> = talos | kubeadm
./node-problem-detector/node-problem-detector-up.sh <distro>

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.

🧬 Talos vs kubeadm#

The outcome is the same on both labs — but not for the same reasons, which is instructive:

Chart monitor Talos kubeadm Decision
kernel-monitor (/dev/kmsg) works works kept
docker-monitor no Docker (containerd) no Docker (containerd) dropped
systemd-monitor (journald) neither systemd nor journald: impossible systemd exists, but Debian keeps the journal VOLATILE (/run/log/journal) while the chart only mounts /var/log ⇒ it would see nothing dropped (recoverable on kubeadm: create /var/log/journal)
Namespace privileged PodSecurity label required (cluster default baseline) intent documentation applied on both

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. Namespace + privileged PodSecurity (/dev/kmsg access)#

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

2. The chart (config trimmed down to the kernel monitor)#

helm repo add deliveryhero https://charts.deliveryhero.io/ && helm repo update deliveryhero
helm upgrade --install node-problem-detector deliveryhero/node-problem-detector \
  -n node-problem-detector --version 2.3.14 \
  --values node-problem-detector/values.yaml
kubectl -n node-problem-detector rollout status daemonset/node-problem-detector --timeout=120s

3. Verify: one pod per node, and the added NodeConditions#

kubectl -n node-problem-detector get pods -o wide
kubectl get nodes -o json | jq -r \
  '.items[] | .metadata.name + " " + ([.status.conditions[] | select(.type|test("KernelDeadlock|ReadonlyFilesystem")) | .type + "=" + .status] | join(" "))'

4. Trigger an event to watch the whole chain (optional)#

kubectl -n node-problem-detector logs ds/node-problem-detector --tail=20
kubectl get events -A --field-selector reason=OOMKilling,reason=TaskHung

🔧 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. The nodes are ordinary Debian VMs, so the simplest path is a shell on the node:

vagrant ssh k8s-w1 -c 'echo "task test:1234 blocked for more than 120 seconds." | sudo tee /dev/kmsg'
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 adaptation work.
  • The KernelDeadlock condition will (almost) never go True on this lab. 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 here either (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 needs root on the node — trivial here (vagrant ssh + sudo), but the write must happen on the node whose condition you are watching: a line injected on k8s-w1 will never show up against k8s-cp1.

📚 References#

🩺node-problem-detector/ — santé des nodes (NodeConditions + Events)

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 au lab (important)#

  • kernel-monitor seulement (/config/kernel-monitor.json, lecture de /dev/kmsg). Le chart charge par défaut kernel-monitor + docker-monitor : le runtime est ici containerd (kubeadm), il n'y a pas de socket Docker → ce moniteur échoue. Le systemd-monitor, lui, lit journald ; systemd existe bien sur Debian 13, mais Debian garde le journal en volatile (/run/log/journal) tant que /var/log/journal n'est pas créé, alors que le chart ne monte que /var/log → il ne verrait rien. values.yaml réduit donc settings.log_monitors au seul kernel-monitor.
  • Namespace en PodSecurity privileged : NPD tourne en privileged (accès /dev/kmsg). kubeadm n'applique aucun niveau au niveau cluster par défaut, mais le script étiquette quand même le namespace : ça documente l'intention et ça tient si on durcit l'admission.
  • Tolérations NoSchedule/Exists → NPD tourne aussi sur les control-plane (cp1/2/3).

⚡ Installation#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> npd     # <distro> = talos | kubeadm
./node-problem-detector/node-problem-detector-up.sh <distro>

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.

🧬 Talos vs kubeadm#

Le résultat est le même sur les deux labs — mais pas pour les mêmes raisons, et c'est instructif :

Moniteur du chart Talos kubeadm Décision
kernel-monitor (/dev/kmsg) fonctionne fonctionne gardé
docker-monitor pas de Docker (containerd) pas de Docker (containerd) retiré
systemd-monitor (journald) ni systemd ni journald : impossible systemd existe, mais Debian laisse le journal VOLATILE (/run/log/journal) alors que le chart ne monte que /var/log ⇒ il ne verrait rien retiré (rattrapable sur kubeadm : créer /var/log/journal)
Label PodSecurity privileged du namespace indispensable (défaut cluster baseline) documentation d'intention posé dans les deux cas

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Namespace + PodSecurity privileged (accès /dev/kmsg)#

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

2. Le chart (config réduite au kernel-monitor)#

helm repo add deliveryhero https://charts.deliveryhero.io/ && helm repo update deliveryhero
helm upgrade --install node-problem-detector deliveryhero/node-problem-detector \
  -n node-problem-detector --version 2.3.14 \
  --values node-problem-detector/values.yaml
kubectl -n node-problem-detector rollout status daemonset/node-problem-detector --timeout=120s

3. Vérifier : 1 pod par node, et les NodeConditions ajoutées#

kubectl -n node-problem-detector get pods -o wide
kubectl get nodes -o json | jq -r \
  '.items[] | .metadata.name + " " + ([.status.conditions[] | select(.type|test("KernelDeadlock|ReadonlyFilesystem")) | .type + "=" + .status] | join(" "))'

4. Provoquer un événement pour voir la chaîne complète (optionnel)#

kubectl -n node-problem-detector logs ds/node-problem-detector --tail=20
kubectl get events -A --field-selector reason=OOMKilling,reason=TaskHung

🔧 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. Les nodes sont de simples VM Debian : le plus direct est un shell sur le node.

vagrant ssh k8s-w1 -c 'echo "task test:1234 blocked for more than 120 seconds." | sudo tee /dev/kmsg'
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.
  • La condition KernelDeadlock ne passera (presque) jamais à True sur ce lab. 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 non plus ici (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 demande root sur le node — trivial ici (vagrant ssh + sudo), mais l'écriture doit se faire sur le node dont on regarde la condition : une ligne injectée sur k8s-w1 ne remontera jamais sur k8s-cp1.

📚 Références#

🐒chaos-kube/ — chaos engineering (chaoskube 0.39)

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#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> chaos     # <distro> = talos | kubeadm

Pinned versions: chart 0.6.0, app v0.39.0.

./chaos-kube/chaoskube-up.sh <distro>

Idempotent (helm upgrade --install), re-runnable. Two knobs:

CHAOS_DRY_RUN=1 ./chaos-kube/chaoskube-up.sh <distro>   # 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.

🧬 Talos vs kubeadm#

No distribution-specific behaviour for this component: same charts, same manifests, same values on both labs. The distribution argument only drives two things here: the default domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env / kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).

⚠️ One consequence to keep in mind, identical on both labs: every Vault pod that gets killed comes back SEALED. After chaoskube hits the vault namespace, run ./vault-cluster/vault-up.sh <distro> to unseal again. That is why values.yaml already excludes vault and cnpg-demo.

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. First, read what will be excluded#

grep -A3 'namespaces' chaos-kube/values.yaml     # kube-system, longhorn-system, vault, cnpg-demo…

Removing no-dry-run makes chaoskube announce its victims in the logs without deleting anything.

helm repo add chaoskube https://linki.github.io/chaoskube/ && helm repo update chaoskube
sed '/^ *no-dry-run:/d' chaos-kube/values.yaml > /tmp/chaos-dry.yaml
helm upgrade --install chaoskube chaoskube/chaoskube -n chaos-kube --create-namespace \
  --version 0.6.0 --values /tmp/chaos-dry.yaml --wait --timeout 5m
kubectl -n chaos-kube logs -f deploy/chaoskube      # "would kill pod …"

3. Switch to the real thing#

helm upgrade --install chaoskube chaoskube/chaoskube -n chaos-kube --create-namespace \
  --version 0.6.0 --values chaos-kube/values.yaml --wait --timeout 5m
kubectl -n chaos-kube rollout status deploy/chaoskube --timeout=180s

4. Read the flags that are actually active (not the ones you think you set)#

kubectl -n chaos-kube get deploy chaoskube \
  -o jsonpath='{range .spec.template.spec.containers[0].args[*]}{@}{"\n"}{end}'

5. Watch the victims#

kubectl get events -A --field-selector reason=Killing --sort-by=.lastTimestamp | tail
kubectl -n chaos-kube logs -f deploy/chaoskube

6. Pause / uninstall#

kubectl -n chaos-kube scale deploy/chaoskube --replicas=0     # pause, without uninstalling
helm -n chaos-kube uninstall chaoskube                        # uninstall

🔧 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#

🐒chaos-kube/ — chaos engineering (chaoskube 0.39)

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#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> chaos     # <distro> = talos | kubeadm

Versions épinglées : chart 0.6.0, app v0.39.0.

./chaos-kube/chaoskube-up.sh <distro>

Idempotent (helm upgrade --install), relançable. Deux molettes :

CHAOS_DRY_RUN=1 ./chaos-kube/chaoskube-up.sh <distro>   # 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.

🧬 Talos vs kubeadm#

Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes, mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou ../Vagrant-KubeADM/).

⚠️ Une conséquence à connaître, la même sur les deux labs : chaque pod Vault tué repart SCELLÉ. Après un passage de chaoskube sur le namespace vault, relance ./vault-cluster/vault-up.sh <distro> pour redesceller. C'est pourquoi values.yaml exclut déjà vault et cnpg-demo.

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Lire d'abord ce qui sera exclu#

grep -A3 'namespaces' chaos-kube/values.yaml     # kube-system, longhorn-system, vault, cnpg-demo…

2. Premier passage en observation seule (fortement conseillé)#

Retirer no-dry-run = chaoskube annonce ses victimes dans ses logs sans rien supprimer.

helm repo add chaoskube https://linki.github.io/chaoskube/ && helm repo update chaoskube
sed '/^ *no-dry-run:/d' chaos-kube/values.yaml > /tmp/chaos-dry.yaml
helm upgrade --install chaoskube chaoskube/chaoskube -n chaos-kube --create-namespace \
  --version 0.6.0 --values /tmp/chaos-dry.yaml --wait --timeout 5m
kubectl -n chaos-kube logs -f deploy/chaoskube      # « would kill pod … »

3. Passer en mode réel#

helm upgrade --install chaoskube chaoskube/chaoskube -n chaos-kube --create-namespace \
  --version 0.6.0 --values chaos-kube/values.yaml --wait --timeout 5m
kubectl -n chaos-kube rollout status deploy/chaoskube --timeout=180s

4. Relire les flags réellement actifs (et non ceux qu'on croit avoir posés)#

kubectl -n chaos-kube get deploy chaoskube \
  -o jsonpath='{range .spec.template.spec.containers[0].args[*]}{@}{"\n"}{end}'

5. Observer les victimes#

kubectl get events -A --field-selector reason=Killing --sort-by=.lastTimestamp | tail
kubectl -n chaos-kube logs -f deploy/chaoskube

6. Mettre en pause / désinstaller#

kubectl -n chaos-kube scale deploy/chaoskube --replicas=0     # pause, sans désinstaller
helm -n chaos-kube uninstall chaoskube                        # désinstallation

🔧 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#

⚖️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.lab.example.io.

🌐 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.lab.example.io → 192.168.56.200 (DNS-only) hostname of the HTTPRoute curl --resolve otherwise (see ✅)
Nothing on the node side Kyverno runs unprivileged, compliant with PodSecurity restricted kubectl -n kyverno get pods

⚡ Install#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> kyverno     # <distro> = talos | kubeadm
./kyverno/kyverno-up.sh <distro>

Versions pinned in the script: chart kyverno/kyverno 3.8.2 (app v1.18.2) and policy-reporter/policy-reporter 3.9.1 (KYVERNO_VERSION / POLICY_REPORTER_VERSION can be overridden). Idempotent (helm upgrade --install + kubectl apply).

🧬 Talos vs kubeadm#

No distribution-specific behaviour for this component: same charts, same manifests, same values on both labs. The distribution argument only drives two things here: the default domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env / kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).

ℹ️ One teaching nuance: the 04-disallow-privileged policy overlaps the baseline PodSecurity level, which is enforced cluster-wide on Talos but not on kubeadm. On kubeadm, Kyverno is therefore what actually provides the guardrail; on Talos it doubles the existing admission (and shows it in Audit mode, which is instructive).

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. The engine#

helm repo add kyverno https://kyverno.github.io/kyverno/ && helm repo update kyverno
helm upgrade --install kyverno kyverno/kyverno -n kyverno --create-namespace \
  --version 3.8.2 --values kyverno/values.yaml
kubectl -n kyverno rollout status deploy/kyverno-admission-controller --timeout=180s
kubectl get crd | grep kyverno.io

2. The teaching policies (validate in Audit, mutate, generate)#

Audit, not Enforce: nothing is blocked, everything is reported. That is the right mode to observe without breaking a lab.

kubectl apply -f kyverno/policies/
kubectl get clusterpolicy

3. Policy Reporter + its UI#

helm repo add policy-reporter https://kyverno.github.io/policy-reporter && helm repo update policy-reporter
helm upgrade --install policy-reporter policy-reporter/policy-reporter -n kyverno \
  --version 3.9.1 --values kyverno/policy-reporter-values.yaml
kubectl -n kyverno rollout status deploy/policy-reporter-ui --timeout=180s

4. The HTTPRoute#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" kyverno/httproute.yaml | kubectl apply -f -

5. Read the reports#

kubectl get policyreport -A
kubectl get clusterpolicyreport
kubectl get policyreport -A -o custom-columns=\
'NS:.metadata.namespace,PASS:.summary.pass,FAIL:.summary.fail,WARN:.summary.warn'

6. See each policy type in action#

# mutate: the label is added automatically
kubectl run demo --image=docker.io/library/busybox --restart=Never -- sleep 60
kubectl get pod demo -o jsonpath='{.metadata.labels}'; echo      # lab.k8s/managed-by=kyverno

# validate (Audit): the pod IS admitted, but the violation is reported
kubectl run bad --image=nginx:latest --restart=Never -- sleep 60  # :latest tag disallowed
kubectl get policyreport -o wide | grep bad

# generate: a default NetworkPolicy appears in a brand-new namespace
kubectl create ns gen-test && kubectl -n gen-test get netpol

kubectl delete pod demo bad --ignore-not-found; kubectl delete ns gen-test
echo "UI: https://kyverno.${LAB_DOMAIN}"

🔧 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.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.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.k8s/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.lab.example.io:443:192.168.56.200 \
  https://kyverno.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.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.k8s/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 kyverno/httproute.yaml
kubectl delete -f 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#

⚖️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.lab.example.io.

🌐 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.lab.example.io → 192.168.56.200 (DNS-only) hostname de l'HTTPRoute curl --resolve sinon (cf. ✅)
Rien côté nodes Kyverno tourne sans privilège, conforme PodSecurity restricted kubectl -n kyverno get pods

⚡ Installation#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> kyverno     # <distro> = talos | kubeadm
./kyverno/kyverno-up.sh <distro>

Versions épinglées dans le script : chart kyverno/kyverno 3.8.2 (app v1.18.2) et policy-reporter/policy-reporter 3.9.1 (KYVERNO_VERSION / POLICY_REPORTER_VERSION surchargeables). Idempotent (helm upgrade --install + kubectl apply).

🧬 Talos vs kubeadm#

Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes, mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou ../Vagrant-KubeADM/).

ℹ️ Une nuance pédagogique : la policy 04-disallow-privileged recoupe le niveau PodSecurity baseline, qui est appliqué au niveau cluster sur Talos mais pas sur kubeadm. Sur kubeadm, c'est donc Kyverno qui apporte réellement le garde-fou ; sur Talos, il double l'admission existante (et le montre en Audit, ce qui est instructif).

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Le moteur#

helm repo add kyverno https://kyverno.github.io/kyverno/ && helm repo update kyverno
helm upgrade --install kyverno kyverno/kyverno -n kyverno --create-namespace \
  --version 3.8.2 --values kyverno/values.yaml
kubectl -n kyverno rollout status deploy/kyverno-admission-controller --timeout=180s
kubectl get crd | grep kyverno.io

2. Les policies pédagogiques (validate en Audit, mutate, generate)#

Audit et non Enforce : rien n'est bloqué, tout est rapporté. C'est le bon mode pour observer sans casser un lab.

kubectl apply -f kyverno/policies/
kubectl get clusterpolicy

3. Policy Reporter + son UI#

helm repo add policy-reporter https://kyverno.github.io/policy-reporter && helm repo update policy-reporter
helm upgrade --install policy-reporter policy-reporter/policy-reporter -n kyverno \
  --version 3.9.1 --values kyverno/policy-reporter-values.yaml
kubectl -n kyverno rollout status deploy/policy-reporter-ui --timeout=180s

4. L'HTTPRoute#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" kyverno/httproute.yaml | kubectl apply -f -

5. Lire les rapports#

kubectl get policyreport -A
kubectl get clusterpolicyreport
kubectl get policyreport -A -o custom-columns=\
'NS:.metadata.namespace,PASS:.summary.pass,FAIL:.summary.fail,WARN:.summary.warn'

6. Voir chaque type de policy à l'œuvre#

# mutate : le label est ajouté automatiquement
kubectl run demo --image=docker.io/library/busybox --restart=Never -- sleep 60
kubectl get pod demo -o jsonpath='{.metadata.labels}'; echo      # lab.k8s/managed-by=kyverno

# validate (Audit) : le pod PASSE, mais la violation est rapportée
kubectl run bad --image=nginx:latest --restart=Never -- sleep 60  # tag :latest interdit
kubectl get policyreport -o wide | grep bad

# generate : une NetworkPolicy par défaut apparaît dans un namespace neuf
kubectl create ns gen-test && kubectl -n gen-test get netpol

kubectl delete pod demo bad --ignore-not-found; kubectl delete ns gen-test
echo "UI : https://kyverno.${LAB_DOMAIN}"

🔧 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.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.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.k8s/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.lab.example.io:443:192.168.56.200 \
  https://kyverno.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.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.k8s/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 kyverno/httproute.yaml
kubectl delete -f 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#

🔎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 ✅ active
ClusterComplianceReport cluster-level CIS / NSA / PSS compliance ✅ active

⚠️ The node-collector needs a privileged pod — THE thing to know here. The last two scanners go through a node-collector pod that bind-mounts /etc/systemd, /lib/systemd, /etc/kubernetes and requires hostPID. Those paths exist and are readable on the Debian 13 nodes, so both scanners are enabled in values.yaml (infraAssessmentScannerEnabled: true, clusterComplianceEnabled: true). But that pod is not admissible under PodSecurity baseline/restricted: kubeadm enforces no level cluster-wide by default, so it runs as-is — if you harden admission, label the namespace privileged. On very small VMs, turning both back to false is a legitimate trade-off; the image / config / secret / RBAC scans are not affected either way.

📋 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
A namespace where the node-collector is admissible it needs hostPID + hostPath; kubeadm enforces no PodSecurity level by default kubectl -n trivy-system get pods

⚡ Install#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> trivy     # <distro> = talos | kubeadm
./trivy-operator/trivy-operator-up.sh <distro>

Versions pinned in the script: chart aqua/trivy-operator 0.34.0 (app v0.32.0) and policy-reporter 3.9.1 (TRIVY_OPERATOR_VERSION / POLICY_REPORTER_VERSION can be overridden). Idempotent.

🧬 Talos vs kubeadm#

One difference, driven by TRIVY_NODE_COLLECTOR (profiles):

Talos kubeadm
operator.infraAssessmentScannerEnabled false true
operator.clusterComplianceEnabled false true
Why the node-collector pod bind-mounts /etc/systemd, /lib/systemd, /etc/kubernetes: Talos has no systemd and / + /etc are read-only ⇒ CreateContainerError: mkdir /etc/systemd: read-only file system (and a baseline PodSecurity rejection on hostPID before that) those paths exist and are readable; the pod runs as-is (no PodSecurity level enforced)
Consequence no "node" reports (infra assessment, cluster compliance) — image / config / secret / RBAC scans keep working full reports, at the cost of one collector pod per node per cycle

values.yaml is shared and encodes the kubeadm case; the script overrides both keys on Talos. On very small VMs, TRIVY_NODE_COLLECTOR=false remains a legitimate trade-off even on kubeadm.

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. Prerequisite: the UI comes from the kyverno add-on#

helm -n kyverno status policy-reporter >/dev/null && echo "Policy Reporter present"

2. Trivy Operator — the last two --set flags depend on the distribution#

helm repo add aqua https://aquasecurity.github.io/helm-charts/ && helm repo update aqua
helm upgrade --install trivy-operator aqua/trivy-operator -n trivy-system --create-namespace \
  --version 0.34.0 \
  --values trivy-operator/values.yaml \
  --set operator.infraAssessmentScannerEnabled=false \   # Talos: false — kubeadm: true
  --set operator.clusterComplianceEnabled=false           # Talos: false — kubeadm: true
kubectl -n trivy-system rollout status deploy/trivy-operator --timeout=180s

3. Wire the Trivy source into the Policy Reporter UI#

helm repo add policy-reporter https://kyverno.github.io/policy-reporter && helm repo update policy-reporter
helm upgrade --install policy-reporter policy-reporter/policy-reporter -n kyverno \
  --version 3.9.1 --values trivy-operator/policy-reporter-values.yaml
kubectl -n kyverno rollout status deploy/policy-reporter-trivy-plugin --timeout=180s

4. Let it run, then read the reports (a few minutes)#

kubectl get vulnerabilityreports -A | head
kubectl get configauditreports -A | head
kubectl get exposedsecretreports -A | head
kubectl get rbacassessmentreports -A | head
# kubeadm only ("node" scanners enabled):
kubectl get infraassessmentreports -A ; kubectl get clustercompliancereports

5. A readable report for a given image#

kubectl get vulnerabilityreports -A -o custom-columns=\
'NS:.metadata.namespace,IMAGE:.report.artifact.repository,CRIT:.report.summary.criticalCount,HIGH:.report.summary.highCount'
echo "UI: https://kyverno.${LAB_DOMAIN}  (source tab \"Trivy\")"

🔧 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 true the node-collector works on Debian nodes (see the callout)
operator.clusterComplianceEnabled true same: compliance aggregates the node data collected above
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.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#

Next to the cluster-wide ClusterComplianceReport, ConfigAuditReport objects give the per-workload view: 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 works here. kubectl get clustercompliancereport lists the k8s-cis-*, k8s-nsa-*, k8s-pss-* definitions shipped by the chart, and their status is populated: the compliance controller is on and the node-collector can read /etc/kubernetes and the systemd units of the Debian nodes. Expect a few minutes and one collector pod per node before the first status.summary shows up.

📈 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 you set infraAssessmentScannerEnabled/clusterComplianceEnabled back to false, the InfraAssessmentReport and ClusterComplianceReport objects already written stay stored, frozen. 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#

🔎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 ✅ actif
ClusterComplianceReport conformité CIS / NSA / PSS au niveau cluster ✅ actif

⚠️ Le node-collector demande un pod privilégié — 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 et exige hostPID. Ces chemins existent et sont lisibles sur les nodes Debian 13 : les deux scanners sont donc activés dans values.yaml (infraAssessmentScannerEnabled: true, clusterComplianceEnabled: true). Mais ce pod n'est pas admissible sous PodSecurity baseline/restricted : kubeadm n'applique aucun niveau au niveau cluster par défaut, il passe donc tel quel — si tu durcis l'admission, étiquette le namespace en privileged. Sur des VM très modestes, les repasser à false reste un arbitrage légitime ; 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
Un namespace où le node-collector est admissible il exige hostPID + des hostPath ; kubeadm n'applique aucun niveau PodSecurity par défaut kubectl -n trivy-system get pods

⚡ Installation#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> trivy     # <distro> = talos | kubeadm
./trivy-operator/trivy-operator-up.sh <distro>

Versions épinglées dans le script : chart aqua/trivy-operator 0.34.0 (app v0.32.0) et policy-reporter 3.9.1 (TRIVY_OPERATOR_VERSION / POLICY_REPORTER_VERSION surchargeables). Idempotent.

🧬 Talos vs kubeadm#

Une différence, portée par TRIVY_NODE_COLLECTOR (profils) :

Talos kubeadm
operator.infraAssessmentScannerEnabled false true
operator.clusterComplianceEnabled false true
Pourquoi le pod node-collector bind-monte /etc/systemd, /lib/systemd, /etc/kubernetes : Talos n'a pas de systemd et / + /etc sont en lecture seule ⇒ CreateContainerError: mkdir /etc/systemd: read-only file system (et refus PodSecurity baseline sur hostPID avant même ça) ces chemins existent et sont lisibles ; le pod passe tel quel (aucun niveau PodSecurity appliqué)
Conséquence pas de rapports « node » (infra assessment, cluster compliance) — les scans images / config / secrets / RBAC continuent normalement rapports complets, au prix d'un pod de collecte par node à chaque cycle

values.yaml est commun et porte le cas kubeadm ; le script surcharge les deux clés sur Talos. Sur des VM très modestes, TRIVY_NODE_COLLECTOR=false reste un arbitrage légitime même sur kubeadm.

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Prérequis : l'UI vient de l'addon kyverno#

helm -n kyverno status policy-reporter >/dev/null && echo "Policy Reporter présent"

2. Trivy Operator — les deux derniers --set dépendent de la distribution#

helm repo add aqua https://aquasecurity.github.io/helm-charts/ && helm repo update aqua
helm upgrade --install trivy-operator aqua/trivy-operator -n trivy-system --create-namespace \
  --version 0.34.0 \
  --values trivy-operator/values.yaml \
  --set operator.infraAssessmentScannerEnabled=false \   # Talos : false — kubeadm : true
  --set operator.clusterComplianceEnabled=false           # Talos : false — kubeadm : true
kubectl -n trivy-system rollout status deploy/trivy-operator --timeout=180s

3. Brancher la source Trivy dans l'UI Policy Reporter#

helm repo add policy-reporter https://kyverno.github.io/policy-reporter && helm repo update policy-reporter
helm upgrade --install policy-reporter policy-reporter/policy-reporter -n kyverno \
  --version 3.9.1 --values trivy-operator/policy-reporter-values.yaml
kubectl -n kyverno rollout status deploy/policy-reporter-trivy-plugin --timeout=180s

4. Laisser tourner, puis lire les rapports (quelques minutes)#

kubectl get vulnerabilityreports -A | head
kubectl get configauditreports -A | head
kubectl get exposedsecretreports -A | head
kubectl get rbacassessmentreports -A | head
# kubeadm uniquement (scanners « node » activés) :
kubectl get infraassessmentreports -A ; kubectl get clustercompliancereports

5. Un rapport lisible pour une image donnée#

kubectl get vulnerabilityreports -A -o custom-columns=\
'NS:.metadata.namespace,IMAGE:.report.artifact.repository,CRIT:.report.summary.criticalCount,HIGH:.report.summary.highCount'
echo "UI : https://kyverno.${LAB_DOMAIN}  (onglet source « Trivy »)"

🔧 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 true le node-collector fonctionne sur des nodes Debian (voir l'encart)
operator.clusterComplianceEnabled true idem : la conformité agrège les données de node collectées ci-dessus
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.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#

À côté du ClusterComplianceReport (vue cluster), les ConfigAuditReport donnent la vue par workload : 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 » fonctionne ici. kubectl get clustercompliancereport liste les définitions k8s-cis-*, k8s-nsa-*, k8s-pss-* livrées par le chart, et leur status est alimenté : le contrôleur de conformité est actif et le node-collector peut lire /etc/kubernetes et les unités systemd des nodes Debian. Compte quelques minutes et un pod collecteur par node avant le premier status.summary.

📈 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 tu repasses infraAssessmentScannerEnabled/clusterComplianceEnabled à false, les InfraAssessmentReport et les ClusterComplianceReport déjà écrits restent en base, figés. 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#

🐙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.lab.example.io behind the same main-gateway as the rest of the lab, with the *.lab.example.io wildcard already issued by cert-manager — nothing new on the certificate side.

🌐 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-lab-example-io-tls READY=True (../cert-manager/) otherwise TLS is untrusted kubectl -n envoy-gateway-system get certificate
DNS argo.lab.example.io → 192.168.56.200 in DNS-only hostname of the HTTPRoute curl --resolve otherwise (see ✅)
Nothing on the node side Argo CD needs no privilege and no hostPath kubectl -n argocd get pods

⚡ Install#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

Through the repository entry point:

./install.sh <distro> argocd     # <distro> = talos | kubeadm
./argocd/argocd-up.sh <distro>

Chart argo/argo-cd 10.2.2 (app v3.4.6), 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.2 \
  --values argocd/values.yaml
kubectl -n argocd rollout status deploy/argocd-server
kubectl apply -f argocd/httproute.yaml

🧬 Talos vs kubeadm#

No distribution-specific behaviour for this component: same charts, same manifests, same values on both labs. The distribution argument only drives two things here: the default domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env / kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. The chart, with the domain substituted in the values#

values.yaml carries global.domain and configs.cm.url: we render a temporary file rather than editing the versioned one.

helm repo add argo https://argoproj.github.io/argo-helm && helm repo update argo
sed "s/lab\.example\.io/${LAB_DOMAIN}/g" argocd/values.yaml > /tmp/argocd-values.yaml
helm upgrade --install argocd argo/argo-cd -n argocd --create-namespace \
  --version 10.2.2 --values /tmp/argocd-values.yaml
kubectl -n argocd rollout status deploy/argocd-server --timeout=300s

2. The HTTPRoute (TLS = the wildcard already held by main-gateway)#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" argocd/httproute.yaml | kubectl apply -f -
kubectl -n argocd get httproute -o wide

3. The initial password#

kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath='{.data.password}' | base64 -d; echo
echo "UI: https://argo.${LAB_DOMAIN}   (user: admin)"

4. Verify the HTTPS path end to end#

curl --resolve "argo.${LAB_DOMAIN}:443:192.168.56.200" "https://argo.${LAB_DOMAIN}/" -kSI | head -1

5. First GitOps cycle (the actual point of this add-on)#

kubectl -n argocd apply -f - <<EOF
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: { name: guestbook }
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 application guestbook -w        # Synced / Healthy

# Self-heal demo: break it by hand, Argo CD puts it back
kubectl -n guestbook delete deploy guestbook-ui
kubectl -n guestbook get deploy -w

🔧 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.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.lab.example.io:443:192.168.56.200 \
  https://argo.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.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.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-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#

🐙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.lab.example.io derrière le même main-gateway que le reste du lab, avec le wildcard *.lab.example.io déjà émis par cert-manager — rien de neuf côté certificat.

🌐 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-lab-example-io-tls READY=True (../cert-manager/) sinon TLS non trusté kubectl -n envoy-gateway-system get certificate
DNS argo.lab.example.io → 192.168.56.200 en DNS-only hostname de l'HTTPRoute curl --resolve sinon (cf. ✅)
Rien côté nodes Argo CD n'a besoin d'aucun privilège ni hostPath kubectl -n argocd get pods

⚡ Installation#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

Via le point d'entrée du dépôt :

./install.sh <distro> argocd     # <distro> = talos | kubeadm
./argocd/argocd-up.sh <distro>

Chart argo/argo-cd 10.2.2 (app v3.4.6), é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.2 \
  --values argocd/values.yaml
kubectl -n argocd rollout status deploy/argocd-server
kubectl apply -f argocd/httproute.yaml

🧬 Talos vs kubeadm#

Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes, mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou ../Vagrant-KubeADM/).

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Le chart, avec le domaine substitué dans les values#

values.yaml porte global.domain et configs.cm.url : on rend un fichier temporaire plutôt que de modifier le fichier versionné.

helm repo add argo https://argoproj.github.io/argo-helm && helm repo update argo
sed "s/lab\.example\.io/${LAB_DOMAIN}/g" argocd/values.yaml > /tmp/argocd-values.yaml
helm upgrade --install argocd argo/argo-cd -n argocd --create-namespace \
  --version 10.2.2 --values /tmp/argocd-values.yaml
kubectl -n argocd rollout status deploy/argocd-server --timeout=300s

2. L'HTTPRoute (TLS = wildcard déjà porté par main-gateway)#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" argocd/httproute.yaml | kubectl apply -f -
kubectl -n argocd get httproute -o wide

3. Le mot de passe initial#

kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath='{.data.password}' | base64 -d; echo
echo "UI : https://argo.${LAB_DOMAIN}   (user : admin)"

4. Vérifier le chemin HTTPS de bout en bout#

curl --resolve "argo.${LAB_DOMAIN}:443:192.168.56.200" "https://argo.${LAB_DOMAIN}/" -kSI | head -1

5. Premier cycle GitOps (le vrai intérêt de l'addon)#

kubectl -n argocd apply -f - <<EOF
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: { name: guestbook }
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 application guestbook -w        # Synced / Healthy

# Démo self-heal : on casse à la main, Argo CD recrée
kubectl -n guestbook delete deploy guestbook-ui
kubectl -n guestbook get deploy -w

🔧 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.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.lab.example.io:443:192.168.56.200 \
  https://argo.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.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.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-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#

📝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.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.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#

🎓 Two paths, same result: the all-in-one script below, or the "Guided walkthrough" section further down — the same commands, one at a time, for training.

kubectl apply -f wordpress-example/wordpress-mariadb.yaml

Everything sits in this single file, namespace wordpress-test included.

🌐 Domain: the manifest carries the neutral domain 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/lab\.example\.io/kubeadm.lab.my-domain.tld/g' \
  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.

🧬 Talos vs kubeadm#

No distribution-specific behaviour for this component: same charts, same manifests, same values on both labs. The distribution argument only drives two things here: the default domain (talos.lab.example.io / kubeadm.lab.example.io) and where the lab's lab.env / kubeconfig live (../Vagrant-Talos/ or ../Vagrant-KubeADM/).

ℹ️ This manifest is applied by hand, so it does NOT get the automatic domain substitution. The sed in step 2 is what does it.

🎓 Guided walkthrough (step by step)#

The commands below are exactly what the all-in-one script does, in order. Set up your shell first (once per session):

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # or ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # or your own (see the lab's lab.env)

1. Prerequisites#

kubectl get sc longhorn                       # the WordPress + MariaDB PVCs
kubectl -n envoy-gateway-system get gateway main-gateway   # the https listener

2. Apply, with the domain substituted#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" wordpress-example/wordpress-mariadb.yaml \
  | kubectl apply -f -
kubectl -n wordpress rollout status deploy/mariadb   --timeout=300s
kubectl -n wordpress rollout status deploy/wordpress --timeout=300s

3. Verify#

kubectl -n wordpress get pvc,svc,httproute
curl --resolve "wordpress.${LAB_DOMAIN}:443:192.168.56.200" \
     "https://wordpress.${LAB_DOMAIN}/wp-admin/install.php" -kSI | head -1
echo "Installer: https://wordpress.${LAB_DOMAIN}"

4. Exercise persistence (the point of the demo)#

kubectl -n wordpress delete pod -l app=wordpress    # the pod restarts, the data stays
kubectl -n wordpress get pv,pvc

5. Clean up#

kubectl delete ns wordpress          # ⚠️ also deletes the PVCs (so the data)

🔧 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.8) DB, strategy: Recreate, volume mounted with subPath: mysql, healthcheck.sh probes
Deployment wordpress (wordpress:7.0-php8.3-apache) front end, Recreate, subPath: wp, probe on /wp-login.php
Service mariadb / wordpress ClusterIP (3306 / 80)
HTTPRoute wordpress wordpress.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.lab.example.io:443:192.168.56.200 \
  https://wordpress.lab.example.io/             # 302 → /wp-admin/install.php (fresh WP)
# then finish the install in the browser: https://wordpress.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.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:7.0-php8.3-apache and mariadb:11.8 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 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#

📝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.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.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#

🎓 Deux chemins, même résultat : le script tout-en-un ci-dessous, ou la section « Pas à pas guidé » plus bas — les mêmes commandes, une par une, pour une formation.

kubectl apply -f wordpress-example/wordpress-mariadb.yaml

Tout est dans ce seul fichier, namespace wordpress-test inclus.

🌐 Domaine : le manifeste porte le domaine neutre 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/lab\.example\.io/kubeadm.lab.mon-domaine.tld/g' \
  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.

🧬 Talos vs kubeadm#

Aucune spécificité de distribution pour ce composant : mêmes charts, mêmes manifestes, mêmes valeurs sur les deux labs. La distribution passée en argument ne sert ici qu'à deux choses : le domaine par défaut (talos.lab.example.io / kubeadm.lab.example.io) et la localisation du lab.env / kubeconfig du lab (../Vagrant-Talos/ ou ../Vagrant-KubeADM/).

ℹ️ Le manifeste est appliqué à la main : il ne bénéficie donc PAS de la substitution automatique du domaine. C'est le sed de l'étape 2 qui s'en charge.

🎓 Pas à pas guidé (formation)#

Les commandes ci-dessous sont exactement ce que fait le script tout-en-un, dans l'ordre. Prépare d'abord l'environnement (une fois par session) :

export KUBECONFIG=../Vagrant-Talos/kubeconfig        # ou ../Vagrant-KubeADM/kubeconfig
export LAB_DOMAIN=talos.lab.example.io               # ou ton domaine (cf. lab.env du lab)

1. Prérequis#

kubectl get sc longhorn                       # les PVC WordPress + MariaDB
kubectl -n envoy-gateway-system get gateway main-gateway   # l'écouteur https

2. Appliquer, domaine substitué#

sed "s/lab\.example\.io/${LAB_DOMAIN}/g" wordpress-example/wordpress-mariadb.yaml \
  | kubectl apply -f -
kubectl -n wordpress rollout status deploy/mariadb   --timeout=300s
kubectl -n wordpress rollout status deploy/wordpress --timeout=300s

3. Vérifier#

kubectl -n wordpress get pvc,svc,httproute
curl --resolve "wordpress.${LAB_DOMAIN}:443:192.168.56.200" \
     "https://wordpress.${LAB_DOMAIN}/wp-admin/install.php" -kSI | head -1
echo "Installation : https://wordpress.${LAB_DOMAIN}"

4. Éprouver la persistance (le but de la démo)#

kubectl -n wordpress delete pod -l app=wordpress    # le pod repart, les données restent
kubectl -n wordpress get pv,pvc

5. Nettoyer#

kubectl delete ns wordpress          # ⚠️ supprime aussi les PVC (donc les données)

🔧 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.8) DB, strategy: Recreate, volume monté en subPath: mysql, sondes healthcheck.sh
Deployment wordpress (wordpress:7.0-php8.3-apache) front, Recreate, subPath: wp, sonde sur /wp-login.php
Service mariadb / wordpress ClusterIP (3306 / 80)
HTTPRoute wordpress wordpress.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.lab.example.io:443:192.168.56.200 \
  https://wordpress.lab.example.io/             # 302 → /wp-admin/install.php (WP frais)
# puis finir l'installation dans le navigateur : https://wordpress.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.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:7.0-php8.3-apache et mariadb:11.8 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 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#