11 KiB
Plan: Idempotent management cluster (Ansible + cluster-api-operator)
Created: 2026-09-27 00:58
Context
Phase 1 step 2 (management cluster + CAPI providers) is next. The runbook today is a series of
hand-typed commands (curl | sh for k3s, clusterctl init), which is not re-runnable and leaves
no declarative record of what the management cluster contains. Goal: one Ansible playbook, run
from the laptop, that creates the management VM on Proxmox, installs pinned k3s, cert-manager,
cluster-api-operator and the five CAPI providers, and fetches kubeconfigs. Re-running it is a
no-op (changed=0). Keep it simple: happy path only, no VM resizing/reconciliation, no HA, no
token rotation automation.
Decisions made by the owner: Ansible playbook; providers via cluster-api-operator Provider CRs
(not clusterctl init); the playbook also creates the VM from the Ubuntu 24.04 cloud image.
Upstream facts verified 2026-09-27 (upstream-researcher, from tags/release assets):
- cluster-api-operator v0.28.0 (chart 0.28.0, repo
https://kubernetes-sigs.github.io/cluster-api-operator) is built against CAPI v1.12.10 → closest match to our v1.12.11 pin (v0.29.0 is on CAPI 1.13.5). Only gate: providermetadata.yamlcontract v1beta1/v1beta2; CAPI v1.12.11, CAPMOX v0.9.1, IPAM in-cluster v1.1.1 all declare v1beta2. - cert-manager must be pre-installed (operator doesn't manage it): v1.21.2, supports k8s 1.33–1.36.
- CRs are
operator.cluster.x-k8s.io/v1alpha2; CR name = provider name, CR namespace = install namespace (must pre-exist);proxmoxandin-clusterare built-in (nofetchConfig). Non-core providers wait for the CoreProvider on their own, so all five can be applied at once. - CAPMOX v0.9.1 reads
PROXMOX_URL,PROXMOX_TOKEN,PROXMOX_SECRETfrom the provider'sconfigSecret. No feature gates needed forlab1(no ClusterClass). - Components are downloaded from GitHub release URLs (not the rate-limited API) and cached per version.
- Re-applying unchanged CRs is a no-op; bumping
spec.versionupgrades in place (one provider at a time). - Gotchas: don't put providers in the operator chart values (they become Helm hooks, #1052);
stale cache secret
<type>-<name>-<ver>-cachemust be deleted by hand (#1075);configSecretis not watched, so treat it as immutable (rotate = new Secret name). clusterctl describe clusterandclusterctl generate cluster --infrastructure proxmox:v0.9.1keep working; don't useclusterctl upgrade/move/deleteagainst an operator-managed cluster.- Ubuntu cloud image pinned:
https://cloud-images.ubuntu.com/noble/20260926/noble-server-cloudimg-amd64.img, sha2566a81c37564db9b1ee84e141922625e1d7c5b389b99bb3c572e0243607d5bb4d2.
Assumptions (confirm at Step 1): root SSH from the laptop to pv1-1 works; ansible-core is
installed on the laptop; the owner provides VMID, static IP, gateway, DNS for the mgmt VM (VLAN 4).
Layout
ansible/
├── ansible.cfg # inventory=inventory.yml, interpreter_python=auto_silent
├── inventory.yml # pve: pv1-1 (root) ; mgmt: lab-mgmt (ubuntu, become)
├── group_vars/all.yml # versions + VM params (no secrets)
├── secrets.yml.example # proxmox_token_secret: "<secret>" → copy to secrets.yml (gitignored)
├── mgmt.yml # the playbook, plays tagged vm / k3s / addons / capi / kubeconfig
└── templates/
├── k3s-config.yaml.j2 # /etc/rancher/k3s/config.yaml
├── helmchart-cert-manager.yaml.j2 # k3s HelmChart CR
├── helmchart-capi-operator.yaml.j2 # k3s HelmChart CR
└── capi-providers.yaml.j2 # namespaces + capmox-variables Secret + 5 provider CRs
Only ansible.builtin modules, no Galaxy collections, no helm/python-kubernetes on the VM: Helm
charts are installed through k3s's built-in helm-controller (HelmChart CRs written to
/var/lib/rancher/k3s/server/manifests/), which keeps them applied and upgrades on file change.
.gitignore: ansible/secrets.yml is already caught by *secret*; add !ansible/secrets.yml.example.
Playbook (ansible/mgmt.yml)
Play 1 — vm (hosts: pve). Guard everything on qm status {{ mgmt_vmid }} (rc≠0 → create):
get_urlthe pinned cloud image to/var/lib/vz/template/iso/withchecksum: sha256:….copythe SSH public key to a temp file; create + configure in one block:qm create <vmid> --name lab-mgmt --cores 2 --memory 4096 --net0 virtio,bridge=vmbr0,tag=4 --scsihw virtio-scsi-pci --scsi0 local-lvm:0,import-from=<img> --ide2 local-lvm:cloudinit --boot order=scsi0 --serial0 socket --vga serial0 --agent 1 --ciuser ubuntu --sshkeys <file> --ipconfig0 ip=<ip>/24,gw=<gw> --nameserver <dns>, thenqm resize <vmid> scsi0 30G,qm start. (Disk lands raw on LVM-thin; bridge + separate tag per lab rules.)wait_forport 22 on the VM IP (delegated to localhost).
Play 2 — k3s (hosts: mgmt, become).
aptqemu-guest-agent(+ service started).template/etc/rancher/k3s/config.yaml:disable: [traefik],tls-san: [<ip>],write-kubeconfig-mode: "0600"; notify restart handler.command: k3s --version(failed_when false) → if it doesn't containk3s_version,get_urlhttps://get.k3s.io to/usr/local/bin/k3s-install.shand run it withINSTALL_K3S_VERSION={{ k3s_version }}(so a version bump inall.yml= upgrade).k3s kubectl wait --for=condition=Ready node --all --timeout=180s(changed_when false).
Play 3 — addons (hosts: mgmt, become).
templatecert-manager HelmChart (repohttps://charts.jetstack.io,v1.21.2,targetNamespace: cert-manager,createNamespace: true,crds.enabled: true).- Wait:
k3s kubectl -n cert-manager rollout status deploy/cert-manager-webhookwithretries/until(deployment appears only after the helm job ran). templatecluster-api-operator HelmChart (0.28.0,targetNamespace: capi-operator-system, no provider values), wait for its deployment rollout, thenk3s kubectl wait --for condition=established crd/coreproviders.operator.cluster.x-k8s.io.
Play 4 — capi (hosts: mgmt, become).
template/opt/lab-mgmt/capi-providers.yaml(mode 0600, holds the token secret): namespacescapi-system,capi-kubeadm-bootstrap-system,capi-kubeadm-control-plane-system,capmox-system,capi-ipam-in-cluster-system; Secretcapmox-system/capmox-variables(PROXMOX_URL,PROXMOX_TOKEN,PROXMOX_SECRET); the five CRs:CoreProvider cluster-api v1.12.11,BootstrapProvider kubeadm v1.12.11,ControlPlaneProvider kubeadm v1.12.11,InfrastructureProvider proxmox v0.9.1(configSecret: {name: capmox-variables}),IPAMProvider in-cluster v1.1.1.k3s kubectl apply -fit;changed_whenoncreated|configuredin stdout.k3s kubectl wait --for=condition=Ready <kind>/<name> -n <ns> --timeout=10mfor all five (condition name to be confirmed on first run; fall back to deployment rollout status).
Play 5 — kubeconfig (hosts: mgmt → localhost).
fetch/etc/rancher/k3s/k3s.yamlto~/.kube/lab-mgmt.kubeconfig(outside the repo),replace127.0.0.1with the VM IP, context renamedlab-mgmt, mode 0600.- Run
.claude/cluster-inspector/make-kubeconfig.sh lab-mgmt --admin-kubeconfig ~/.kube/lab-mgmt.kubeconfig --apply-rbac(taggedinspector,changed_when: false; it re-mints the 7-day token each run).
Execution steps
- Step 0 — Repo has no commits yet: commit the existing tree as the initial import, then
copy this plan to
docs/plans/<ts>-mgmt-cluster-ansible.mdand commit it alone. - Step 1 — Scaffold
ansible/(all files above),.gitignoreexception, owner fills VMID/IP/ gw/DNS ingroup_vars/all.ymlandsecrets.yml. Check:ansible-playbook mgmt.yml --syntax-check,ansible -m ping all(reaches pv1-1; mgmt unreachable until Play 1 ran). - Step 2 — Owner runs
ansible-playbook mgmt.yml --tags vm; then--tags k3s; then--tags addons; then--tags capi; then--tags kubeconfig,inspector. Per CLAUDE.md safety rules the owner runs lab mutations (or explicitly tells Claude to); Claude diagnoses failures via thecluster-inspectoragent and records exact commands/outputs in the execution log. - Step 3 — Idempotency check: full
ansible-playbook mgmt.ymltwice; second run's recap must showchanged=0(fix any task that reports changed spuriously). - Step 4 — Docs: rewrite phase-1 Step 2 around the playbook (prereqs: ansible-core, pinned
clusterctl v1.12.11 darwin binary on the laptop for Step 3's
generate/describe; removeclusterctl init; note "noclusterctl upgrade/move/delete"); add troubleshooting rows (provider CR not Ready → operator logs; stale cache secret #1075);versions.mdrows for cluster-api-operator 0.28.0, cert-manager v1.21.2, Ubuntu cloud image serial 20260926 + upgrade order note (bump provider CRs one at a time);configs/capi/clusterctl.yaml.example: controller credentials section marked as unused (now inansible/secrets.yml); CLAUDE.md version rule "explicit versions toclusterctl init" → "…ingroup_vars/all.yml/ provider CRs";docs/decisions.mdrows: Ansible for mgmt setup (alt: cloud-init + k3s manifests, bash, OpenTofu), cluster-api-operator (alt: guardedclusterctl init), mgmt VM from cloud image (alt: manual VM). Tick phase-1 checklist Step 2 and README status only after Step 3 passes; CHANGELOG entry after the owner confirms.
Critical files
- New:
ansible/**(above) - Edit:
.gitignore,docs/phase-1-proxmox-capi.md(Step 2, troubleshooting),docs/versions.md,docs/decisions.md,configs/capi/clusterctl.yaml.example,CLAUDE.md(version rules),README.md(status) - Reuse:
.claude/cluster-inspector/make-kubeconfig.sh+rbac.yaml(Play 5), existing Proxmox facts fromconfigs/capi/clusterctl.yaml.example(URL, token id, node, bridge/VLAN)
Verification
ansible-playbook mgmt.yml --syntax-checkpasses.- After the full run:
KUBECONFIG=~/.kube/lab-mgmt.kubeconfig kubectl get coreproviders,bootstrapproviders,controlplaneproviders,infrastructureproviders,ipamproviders -A→ all Ready with the pinned versions;kubectl get pods -A→ capi/capmox/ipam/cert-manager/operator Running. kubectl -n capmox-system logs deploy/capmox-controller-managershows no Proxmox auth errors.clusterctl generate cluster lab1 --infrastructure proxmox:v0.9.1 --kubernetes-version v1.35.9 ... --list-variablesworks against the new cluster (readiness for phase-1 Step 3).- Second full playbook run:
changed=0. cluster-inspectoragent can read the cluster withlab-mgmt-ro(can-ioutput as in its README).