Initial import: phase docs, configs, Claude conventions

Phase 1-3 runbooks and design notes, version pins, config templates,
project-history conventions, cluster-inspector read-only credentials setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 00:58:52 +02:00
co-authored by Claude Opus 5.5
commit 5fd998c5f9
26 changed files with 1124 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
---
name: cluster-inspector
description: Read-only inspection and triage of the lab management cluster (CAPI, CAPMOX, IPAM, cluster-autoscaler) and workload clusters. Use when a cluster, Machine, ProxmoxMachine, node or autoscaling behaviour needs diagnosing. Never mutates anything.
tools: Bash, Read, Grep
---
You diagnose Cluster API / cluster-autoscaler issues in the lab. You are strictly read-only:
only `kubectl get/describe/logs/events/explain`, `clusterctl describe`, `kubectl auth can-i`,
and read-only Proxmox commands (`pvesh get`, `qm config`, `qm list`, `qm guest cmd … network-get-interfaces`).
Never run apply/create/delete/patch/edit/scale/annotate/label/cordon/drain, `qm set/start/stop/destroy`,
`pveum` changes, or anything that writes. If a fix needs a mutation, output the exact command
for the owner to run.
Credentials — use ONLY the read-only kubeconfigs, never ~/.kube/config or any admin kubeconfig:
- Files: `$(git rev-parse --show-toplevel)/.claude/cluster-inspector/kubeconfigs/<cluster>.kubeconfig`,
context `<cluster>-ro` (e.g. `lab-mgmt`, `lab1`). List the directory to see which clusters exist.
- Always pass it explicitly: `KUBECONFIG=<file> kubectl ...` (or `--kubeconfig <file>`).
- If the file is missing, or kubectl returns 401 (expired token), stop and tell the owner to run
`.claude/cluster-inspector/make-kubeconfig.sh <cluster> ...` (see .claude/cluster-inspector/README.md).
- Secrets are intentionally not readable; a 403 on secrets is expected, don't work around it.
Triage order:
1. `clusterctl describe cluster <name> --show-conditions all`
2. Objects + conditions: `kubectl get cluster,kcp,md,ms,machines,proxmoxmachines -A -o wide`,
`kubectl describe` the first non-Ready object.
3. Controller logs: `capi-system/capi-controller-manager`, `capmox-system/capmox-controller-manager`,
`capi-kubeadm-*-system`, IPAM (`kubectl get ipaddressclaims,ipaddresses -A`).
4. Node side: Node vs Machine providerID match
(`kubectl get nodes -o custom-columns=N:.metadata.name,P:.spec.providerID`), kubelet/cloud-init
status if SSH access is given.
5. Autoscaler: CA logs (node group discovery, scale decisions), pending pod events
(`TriggeredScaleUp` / `NotTriggerScaleUp`), MachineDeployment min/max + capacity annotations,
whether `spec.replicas` is being fought by a re-apply.
Known failure → fix mappings live in docs/phase-1-proxmox-capi.md and docs/image-builder-proxmox.md;
check them first. Report: root cause (with evidence lines), fix command(s), and anything unverified.
+26
View File
@@ -0,0 +1,26 @@
---
name: upstream-researcher
description: Answers "does upstream project X (CAPI providers, cluster-autoscaler, image-builder, Kamaji, KubeVirt, packer plugins) support Y / how does it behave / which versions are compatible" from the tagged release's source code, not READMEs. Use for provider evaluations, version-compatibility questions and root-causing upstream behaviour. Read-only.
tools: Bash, Read, Grep, Glob, WebFetch, WebSearch
---
You research upstream Kubernetes ecosystem projects for a home-lab Cluster API project
(see CLAUDE.md and docs/versions.md for the pinned stack). You never modify the repository.
Method:
1. Identify the exact module path and the release in question (pinned version from docs/versions.md,
or latest if asked). Latest: `curl -sS https://proxy.golang.org/<module>/@latest`;
all tags: `git ls-remote --tags <repo-url>` (works when the GitHub API is blocked).
2. Fetch the source of that exact tag into a temp dir, e.g.
`curl -sSL -o src.zip https://proxy.golang.org/<module>/@v/<ver>.zip && unzip -q src.zip -d src`
(non-Go projects: `git clone --depth 1 --branch <tag>`).
3. Answer from code: API types (`api/*/…_types.go`), webhooks, controllers, `templates/`,
`metadata.yaml` (contract), `go.mod` (CAPI / k8s versions), Makefile / hack scripts.
4. For compatibility: compare the CAPI version in go.mod and the contract in metadata.yaml with the
pinned CAPI core; check the project's own compatibility table.
Report format (concise, SRE audience):
- Answer in 1–3 sentences.
- Evidence: file path + line or a short code excerpt from the exact tag, plus the version checked.
- Caveats / version constraints relevant to our pinned stack.
- If something could not be verified, say so explicitly — never fill gaps from memory.
+69
View File
@@ -0,0 +1,69 @@
# cluster-inspector: credentials setup
The `cluster-inspector` agent (`.claude/agents/cluster-inspector.md`) only ever talks to clusters
through the read-only kubeconfigs in `kubeconfigs/` here. They're gitignored and minted by
`make-kubeconfig.sh` from a short-lived ServiceAccount token.
| File | Purpose |
| --- | --- |
| `rbac.yaml` | ServiceAccount `kube-system/readonly-inspector` + `view` + read on CAPI CRDs and pod logs (no Secrets) |
| `make-kubeconfig.sh` | applies RBAC (once), mints a TokenRequest token, writes `kubeconfigs/<name>.kubeconfig`, runs `can-i` checks |
| `kubeconfigs/` | output, **gitignored**; one file per cluster, context `<name>-ro` |
## 1. Management cluster (after phase 1 step 2)
k3s writes `server: https://127.0.0.1:6443`, so pass the VM address. k3s puts node IPs into the
API cert SANs, so the IP works; add `--tls-san` for a DNS name.
```bash
scp ubuntu@<mgmt-vm>:/etc/rancher/k3s/k3s.yaml /tmp/lab-mgmt-admin.yaml # or use your existing admin kubeconfig
.claude/cluster-inspector/make-kubeconfig.sh lab-mgmt \
--admin-kubeconfig /tmp/lab-mgmt-admin.yaml \
--server https://<mgmt-vm-ip>:6443 \
--apply-rbac
rm /tmp/lab-mgmt-admin.yaml
```
## 2. Workload clusters (after phase 1 step 3)
```bash
clusterctl get kubeconfig lab1 > /tmp/lab1-admin.yaml # run against the mgmt cluster (admin)
.claude/cluster-inspector/make-kubeconfig.sh lab1 --admin-kubeconfig /tmp/lab1-admin.yaml --apply-rbac
rm /tmp/lab1-admin.yaml
```
## Expected verification output
```
list pods yes
get machines.cluster.x-k8s.io yes # "no" on workload clusters (no CAPI CRDs), fine
delete machines.cluster.x-k8s.io no
patch machinedeployments.cluster.x-k8s.io no
get secrets no
```
Anything else means the RBAC is wrong. Don't use the agent until it matches.
## Refresh / revoke
- **Refresh:** re-run the same command without `--apply-rbac`. The default lifetime is 7 days
(`--duration`); the API server may cap it (`--service-account-max-token-expiration`).
- **Revoke everything:** `kubectl delete sa -n kube-system readonly-inspector` (as admin). That
invalidates every minted token; re-apply `rbac.yaml` to start over.
## Making read-only the default for this repo (recommended)
RBAC only protects you when the read-only kubeconfig is actually used. Your admin
`~/.kube/config` is still reachable from any shell. To make Claude Code sessions in this repo
default to the read-only files, add this to `.claude/settings.local.json` (personal, not committed):
```json
{
"env": {
"KUBECONFIG": "/Users/jan.novak/srv/go/cluster-autoscaler-with-capi/.claude/cluster-inspector/kubeconfigs/lab-mgmt.kubeconfig:/Users/jan.novak/srv/go/cluster-autoscaler-with-capi/.claude/cluster-inspector/kubeconfigs/lab1.kubeconfig"
}
}
```
Then plain `kubectl --context lab-mgmt-ro …` / `--context lab1-ro …` works everywhere in the
session, and mutations fail on RBAC even if something tries them.
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Mint a read-only kubeconfig for the cluster-inspector agent.
#
# Usage: make-kubeconfig.sh <name> [options]
# <name> cluster name, e.g. lab-mgmt or lab1 -> kubeconfigs/<name>.kubeconfig
# --admin-kubeconfig PATH admin kubeconfig used to apply RBAC / mint the token (default: $KUBECONFIG or ~/.kube/config)
# --context CTX context inside the admin kubeconfig
# --server URL override API server URL (k3s.yaml says https://127.0.0.1:6443)
# --duration DUR token lifetime (default 168h); re-run the script to refresh
# --apply-rbac kubectl apply rbac.yaml first (needed once per cluster)
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
name=""; admin="${KUBECONFIG:-$HOME/.kube/config}"; ctx=""; server=""; duration="168h"; apply=0
while [[ $# -gt 0 ]]; do
case "$1" in
--admin-kubeconfig) admin="$2"; shift 2 ;;
--context) ctx="$2"; shift 2 ;;
--server) server="$2"; shift 2 ;;
--duration) duration="$2"; shift 2 ;;
--apply-rbac) apply=1; shift ;;
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
-*) echo "unknown option $1" >&2; exit 1 ;;
*) name="$1"; shift ;;
esac
done
[[ -n "$name" ]] || { sed -n '2,10p' "$0" >&2; exit 1; }
k=(kubectl --kubeconfig "$admin")
[[ -n "$ctx" ]] && k+=(--context "$ctx")
if (( apply )); then "${k[@]}" apply -f "$here/rbac.yaml"; fi
token="$("${k[@]}" -n kube-system create token readonly-inspector --duration "$duration")"
[[ -n "$server" ]] || server="$("${k[@]}" config view --raw --minify --flatten -o jsonpath='{.clusters[0].cluster.server}')"
ca="$("${k[@]}" config view --raw --minify --flatten -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')"
[[ -n "$ca" ]] || { echo "no certificate-authority-data in admin kubeconfig" >&2; exit 1; }
mkdir -p "$here/kubeconfigs"
out="$here/kubeconfigs/$name.kubeconfig"
umask 077
cat > "$out" <<EOF
apiVersion: v1
kind: Config
clusters:
- name: $name
cluster:
server: $server
certificate-authority-data: $ca
users:
- name: $name-readonly-inspector
user:
token: $token
contexts:
- name: $name-ro
context:
cluster: $name
user: $name-readonly-inspector
current-context: $name-ro
EOF
echo "wrote $out (context $name-ro, server $server, token valid $duration)"
echo "verification (expect yes, yes, no, no, no; CAPI rows say no on workload clusters):"
for check in "list pods" "get machines.cluster.x-k8s.io" "delete machines.cluster.x-k8s.io" \
"patch machinedeployments.cluster.x-k8s.io" "get secrets"; do
printf ' %-45s %s\n' "$check" "$(KUBECONFIG="$out" kubectl auth can-i $check -A 2>/dev/null || true)"
done
+43
View File
@@ -0,0 +1,43 @@
# Read-only identity for the cluster-inspector agent.
# Apply to the management cluster and to every workload cluster the agent should inspect
# (CAPI rules are simply unused on workload clusters).
# Built-in `view` excludes Secrets; capi-readonly adds read access to CAPI CRDs and pod logs.
# Tokens are short-lived TokenRequest tokens minted by make-kubeconfig.sh — no Secret-based token.
apiVersion: v1
kind: ServiceAccount
metadata:
name: readonly-inspector
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: capi-readonly
rules:
- apiGroups:
- cluster.x-k8s.io
- infrastructure.cluster.x-k8s.io
- controlplane.cluster.x-k8s.io
- bootstrap.cluster.x-k8s.io
- ipam.cluster.x-k8s.io
- addons.cluster.x-k8s.io
- clusterctl.cluster.x-k8s.io
resources: ["*"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: readonly-inspector-view
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: view }
subjects: [{ kind: ServiceAccount, name: readonly-inspector, namespace: kube-system }]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: readonly-inspector-capi
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: capi-readonly }
subjects: [{ kind: ServiceAccount, name: readonly-inspector, namespace: kube-system }]
+23
View File
@@ -0,0 +1,23 @@
---
description: Commit current changes and push to the remote
---
Commit the current working-tree changes and push them.
1. Run `git status` and `git diff` to see what will be committed.
2. Check the staged content for secrets: Proxmox token secrets, kubeconfig contents, anything
`.gitignore` would have caught by name but that was pasted into a doc. Stop and report if found.
3. Check the current branch (`git branch --show-current`). Docs, configs and history files may go
straight to `main`. If on `main` and the diff contains phase-3 Go code or something the user
called a feature, stop and tell the user to branch first (CLAUDE.md "Git").
4. If an approved plan is in progress, make sure its execution log in `docs/plans-executions/` is
updated and its checklist ticked for the steps included in this commit.
5. Stage the changed files (`git add -A` is fine here).
6. Commit with a short message inferred from the diff, ending with the `Co-Authored-By:` trailer
for Claude. Pass the message via a HEREDOC.
7. Push with `git push`; if the branch has no upstream, `git push -u origin HEAD`.
8. If the branch isn't `main`, remind the user to open an MR with `tea pr create` rather than
opening one automatically.
9. Report the commit hash and push result.
If there are no changes, stop and say so; do not create an empty commit.
+14
View File
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git branch:*)",
"Bash(git add:*)",
"Bash(date:*)",
"Bash(tea pr create:*)",
"Read(//private/tmp/**)"
]
}
}
+12
View File
@@ -0,0 +1,12 @@
# credentials & generated kubeconfigs
.claude/cluster-inspector/kubeconfigs/
.claude/settings.local.json
*.kubeconfig
kubeconfig*
*secret*
clusterctl.yaml
!configs/capi/clusterctl.yaml.example
.env
# packer / image-builder output
packer.log
*.iso
+19
View File
@@ -0,0 +1,19 @@
# Changelog
## 2026-09-27 00:02 CEST — Read-only cluster-inspector credentials next to the agent
- Moved inspector RBAC from `configs/rbac/` to `.claude/cluster-inspector/` with `make-kubeconfig.sh`
(applies RBAC once, mints a short-lived TokenRequest token, writes gitignored
`kubeconfigs/<name>.kubeconfig`, runs `can-i` checks) and a setup README.
- Dropped the long-lived `service-account-token` Secret; tokens default to 7 days, refresh by re-running.
- `cluster-inspector` agent now reads only `.claude/cluster-inspector/kubeconfigs/*`; `.gitignore`
covers that dir and `.claude/settings.local.json`.
- Files: `.claude/cluster-inspector/`, `.claude/agents/cluster-inspector.md`, `CLAUDE.md`, `README.md`,
`.gitignore`, `docs/decisions.md`.
## 2026-09-26 23:55 CEST — Project history conventions
- Adopted plan / execution-log / prompt / review / changelog conventions from
egress-proxies-operator, plus new `docs/decisions.md` (back-filled) and `docs/sessions/`.
- Git flow: docs straight to `main`, phase-3 code via `feat/` branch + `tea` MR; `/ship` command.
- Files: `CLAUDE.md`, `docs/decisions.md`, `docs/sessions/`, `.claude/`.
+98
View File
@@ -0,0 +1,98 @@
# CLAUDE.md
Home-lab research project: cluster-autoscaler + Cluster API on Proxmox VE (phase 1), then a
KVM/libvirt provider (phase 2) and Kamaji hosted control planes / "poor man's KaaS" (phase 3).
Start with README.md; details live in docs/.
## Owner context
Experienced SRE. Be concise and technical; skip basics (k8s internals, Go, Linux, distributed systems).
## Lab facts (don't re-derive)
- PVE node `pv1-1`, API `https://192.168.0.8:8006`, token `capmox@pve!capi` (privsep 0).
- VM network: `vmbr0` is VLAN-aware, VMs in **VLAN 4** → always `bridge=vmbr0` + tag/`vlan: 4`,
never `vmbr0.4@vmbr0` (that is an `ip link` name, not a bridge).
- Storage: `local` (iso, dir) and `local-lvm` (LVM-thin) → disks are **raw**, never qcow2.
- Build host for image-builder: 192.168.4.181 (VLAN 4 must reach it on 8000–9000 during builds).
- Node template: `ubuntu-2404-kube-v1.35.9` (image-builder v0.1.55). Login only via cloud-init.
- Required PVE ACLs: PVEVMAdmin + PVEAuditor on `/`, PVEDatastoreAdmin on `/storage`,
PVESDNUser on `/sdn/zones/localnetwork`.
## Version rules
- `docs/versions.md` is the source of truth. Any version change updates it in the same change.
- Before proposing any deployment or upgrade, verify versions are current (use the
`k8s-version-check` skill if available). Go module proxy / git ls-remote / Helm index.yaml work
even when the GitHub API is unavailable.
- CAPI core is pinned to **v1.12.x** because CAPMOX v0.9 is only tested on CAPI 1.11/1.12. This caps
workload Kubernetes at 1.35 and cluster-autoscaler at 1.35.x. Don't bump CAPI unless the CAPMOX
compatibility table allows it.
- Always pass explicit versions to `clusterctl init` / `helm install`; never rely on "latest".
- Check a provider's `metadata.yaml` contract and CAPI version (go.mod) before adding it to the
management cluster — all providers share one CAPI core (known skew: Kamaji CP provider v0.21.0 is
on CAPI 1.13).
## Facts about providers: verify in source
READMEs lag. For "does provider X support Y" read the API types and templates of the exact tagged
release (Go proxy zip: `https://proxy.golang.org/<module>/@v/<ver>.zip`). Delegate to the
`upstream-researcher` agent for anything non-trivial.
## Safety
- Never commit secrets: PVE token secret, kubeconfigs, `clusterctl.yaml` (see .gitignore).
- Cluster inspection is read-only via the `cluster-inspector` agent and read-only kubeconfigs
(`.claude/cluster-inspector/`, see its README). Mutations (`kubectl apply/delete/scale`, `qm`, `pveum`)
are proposed as commands for the owner to run unless explicitly asked to execute them.
- Don't delete Proxmox VMs/templates or CAPI objects on your own initiative.
## Conventions
- Docs in English, Markdown, Mermaid for diagrams (Gitea renders it).
- Troubleshooting findings go into the relevant doc's troubleshooting/fixes table:
symptom → root cause → fix.
- Example configs live in `configs/` as `*.example` or non-secret values files.
## Project history
Research repo: plans, conversations and decisions are kept in the repo, dated. Timestamps always
come from `date` (`+%Y-%m-%d-%H%M` for file names, `+%Y-%m-%d %H:%M %Z` for headings), never guessed.
Summaries only, never raw transcripts; mask secrets and check the staged diff before committing.
| What | Where | When |
| --- | --- | --- |
| Plans | `docs/plans/YYYY-MM-DD-HHMM-<slug>.md` | every plan-mode plan |
| Execution logs | `docs/plans-executions/<plan's file name>` | after each step of an approved plan |
| Session notes | `docs/sessions/YYYY-MM-DD-HHMM-<slug>.md` | research/design/debugging conversations without a plan |
| Decisions | `docs/decisions.md` (one table) | a direction chosen, rejected or reversed |
| Prompts | `docs/prompts/YYYY-MM-DD-HHMM-<slug>.md` | long crafted prompts/specs that kick off work (verbatim) |
| Reviews | `docs/reviews/YYYY-MM-DD-HHMM-<slug>.md` | review findings, most severe first, `- [ ]` ticked as processed |
| Changelog | `CHANGELOG.md`, newest on top | lab milestone, version change, design direction, new runbook/config — after the owner confirms it works |
- **Plans:** header `# Plan: <title>` + `**Created:** <timestamp>`. Plan mode can only write to
`~/.claude/plans/`, so right after `ExitPlanMode` the first action is copying the plan into
`docs/plans/` and committing it on its own, before any work starts.
- **Execution logs:** a `- [ ] Step N — <title>` checklist on top (the authoritative status; a new
session resumes from this file alone), then one section per step in chronological order,
committed with that step's work. Light prose; exact commands as fenced blocks (`clusterctl`,
`helm`, `kubectl apply`, `qm`, Packer), each with one line on why it was run that way, including
commands the owner reports running on the lab and outputs that mattered (VM IDs, IPs, resolved
versions, errors). Call out deviations from the plan; end with "Worth noting". Problems also go
into the relevant fixes table, linked from the log.
- **Session notes:** write one unprompted when a conversation produced a finding worth keeping
(option compared or rejected, compatibility check, root cause, open question) that no plan
covers; mention it in the reply. Sections: Question, What was checked (sources, versions with
date checked), Findings, Open questions, Follow-ups. Extend the note if the topic continues.
- **Decisions:** never rewrite a row; a reversal is a new row and the old one becomes
`superseded by #N`. Design-doc suggestions are `proposed` until lab work confirms them. Link
the plan/session/doc with the reasoning. README "Key decisions so far" stays the short summary.
## Git
- Remote: Gitea `gitea.home.hrajfrisbee.cz/kacerr/cluster-autoscaler-with-capi`.
- Docs, configs and history files go straight to `main`. Phase-3 Go code or anything the owner
calls a feature: `feat/<slug>` branch, `git push -u`, MR via
`tea pr create --title … --description … --base main --head <branch>` (fallback: print
`…/compare/main...<branch>`). Never merge or delete branches from the CLI.
- Commit messages end with a `Co-Authored-By:` trailer for Claude. `/ship` commits and pushes.
+115
View File
@@ -0,0 +1,115 @@
# cluster-autoscaler-with-capi
Node autoscaling for Kubernetes clusters running as VMs on a home-lab hypervisor fleet
(Proxmox VE + vanilla KVM/libvirt), driven by **Cluster API** and **cluster-autoscaler** —
growing, in later phases, into a "poor man's Kubernetes-as-a-Service".
> Status: **Phase 1 in progress** — VM template built and smoke-tested; management cluster
> and first workload cluster next. Versions verified 2026-09-24/26, see [docs/versions.md](docs/versions.md).
## Goals
- Workload clusters whose worker pool scales **0 → N → 0** on demand, with VMs created and
destroyed on the hypervisors automatically.
- Everything declarative: clusters are CAPI objects in a management cluster; no hand-made VMs.
- Learn Cluster API internals (contracts, providers, reconcile flow) by assembling, then extending it.
- End state: self-service tenant clusters (hosted control planes + autoscaled VM workers).
## Non-goals (for now)
- HA management cluster, production SLAs, multi-site.
- GitOps / fleet management (may come later on top).
- Writing our own autoscaler — cluster-autoscaler's `clusterapi` provider does the job.
## Lab inventory
| Hypervisor type | Management API | CAPI infrastructure provider |
| --- | --- | --- |
| Proxmox VE (e.g. `pv1-1`, 192.168.0.8) | PVE REST API :8006 | CAPMOX (phase 1) |
| Ubuntu 24.04 + KVM/libvirt | libvirt RPC (`qemu+ssh`/`qemu+tls`) | none mature upstream → phase 2 |
VM network: `vmbr0` (VLAN-aware), VLAN 4, build host 192.168.4.181.
## Architecture (phase 1)
```mermaid
flowchart LR
subgraph MGMT[mgmt cluster: k3s VM]
CAPI[CAPI core + kubeadm bootstrap/CP]
CAPMOX[CAPMOX]
IPAM[IPAM in-cluster]
CA[cluster-autoscaler]
end
PVE[Proxmox VE API]
subgraph LAB1[workload cluster lab1]
CP[control plane VM + kube-vip VIP]
W[worker VMs - MachineDeployment 0..N]
end
CA -- scales MachineDeployment --> CAPI
CAPI --> CAPMOX
CAPMOX -- clone template, cloud-init ISO --> PVE
PVE --> CP
PVE --> W
CA -- watches pending pods via lab1-kubeconfig --> LAB1
```
cluster-autoscaler runs in the management cluster in `kubeconfig-incluster` mode: it reads
pending pods/nodes from the workload cluster through the CAPI-generated kubeconfig secret and
changes `replicas` on MachineDeployments via the in-cluster management API.
## Roadmap
| Phase | Scope | Exit criteria | Doc |
| --- | --- | --- | --- |
| **1** | k3s mgmt cluster (manual) → CAPI + CAPMOX → workload cluster on Proxmox VMs → cluster-autoscaler | scale-up 1→3, scale-down to 0, scale-from-zero all work | [phase-1-proxmox-capi.md](docs/phase-1-proxmox-capi.md) |
| **2** | CAPI provider for vanilla KVM/libvirt hosts (evaluate CAPLV / KubeVirt / Metal3 / own provider) | worker MachineDeployment on Ubuntu KVM hosts, autoscaled | [phase-2-kvm-provider.md](docs/phase-2-kvm-provider.md) |
| **3** | Hosted control planes with Kamaji, tenant clusters on demand ("poor man's KaaS") | new tenant cluster from one manifest/ClusterClass, workers autoscaled | [phase-3-kamaji-kaas.md](docs/phase-3-kamaji-kaas.md) |
Phases 2 and 3 are independent and can be done in either order.
## Repository layout
```
.
├── README.md # this file
├── CLAUDE.md # context + rules for Claude Code sessions
├── CHANGELOG.md # milestones and significant changes, newest first
├── .claude/
│ ├── agents/ # upstream-researcher, cluster-inspector (read-only)
│ ├── commands/ # /ship
│ └── cluster-inspector/ # RBAC, kubeconfig minting script, gitignored kubeconfigs/
├── docs/
│ ├── decisions.md # decision log: alternatives, why, status
│ ├── plans/ # plans (plan mode), one file per plan
│ ├── plans-executions/ # per-plan execution logs with exact commands
│ ├── sessions/ # research/design conversation summaries
│ ├── prompts/ # prompts/specs that kicked off work
│ ├── reviews/ # review findings
│ ├── versions.md # pinned versions + compatibility reasoning
│ ├── phase-1-proxmox-capi.md # runbook for phase 1
│ ├── image-builder-proxmox.md # building the node VM template + fixes log
│ ├── phase-2-kvm-provider.md # KVM/libvirt provider options
│ └── phase-3-kamaji-kaas.md # hosted control planes / KaaS design notes
└── configs/
├── image-builder/ # packer var file for the Proxmox template
├── capi/ # clusterctl config example
├── cluster-autoscaler/ # Helm values
└── test/ # workloads for scale tests
```
Secrets (PVE token secret, kubeconfigs) never go into the repo — see `.gitignore`.
## Key decisions so far
Short summary; the full log with alternatives and status is [docs/decisions.md](docs/decisions.md).
1. **CAPI v1.12.x, not the latest 1.14.x** — CAPMOX v0.9.x is only tested against CAPI 1.11/1.12.
This caps workload Kubernetes at **1.35** and cluster-autoscaler at **1.35.x**.
2. **Management cluster = k3s on a Proxmox VM**, not kind — CAPI controllers and the autoscaler
must run permanently.
3. **Node images via kubernetes-sigs/image-builder** (Ubuntu 24.04, Proxmox builder), overrides in
a `PACKER_VAR_FILES` JSON instead of `PACKER_FLAGS`.
4. **CNI installed by Helm** (Cilium, `ipam.mode=kubernetes`), not via ClusterResourceSet flavors,
to keep its version under our control.
5. **Scale-from-zero via capacity annotations** on the MachineDeployment — CAPMOX does not publish
`status.capacity` on `ProxmoxMachineTemplate`.
+26
View File
@@ -0,0 +1,26 @@
# copy to ~/.cluster-api/clusterctl.yaml — never commit the real file
## controller
PROXMOX_URL: "https://192.168.0.8:8006"
PROXMOX_TOKEN: "capmox@pve!capi"
PROXMOX_SECRET: "<secret>"
## workload defaults
PROXMOX_SOURCENODE: "pv1-1" # node holding the template
TEMPLATE_VMID: "<vmid of ubuntu-2404-kube-v1.35.9>"
ALLOWED_NODES: "[pv1-1]" # more nodes only with shared storage
VM_SSH_KEYS: "ssh-ed25519 AAAA..."
## network (VLAN 4 on vmbr0 — add `vlan: 4` to the network device in ProxmoxMachineTemplates)
CONTROL_PLANE_ENDPOINT_IP: "<free IP in VLAN 4>"
NODE_IP_RANGES: "[<first>-<last>]"
GATEWAY: "<gw>"
IP_PREFIX: "24"
DNS_SERVERS: "[<dns>]"
BRIDGE: "vmbr0"
## sizing defaults (workers overridden in lab1.yaml)
BOOT_VOLUME_DEVICE: "scsi0"
BOOT_VOLUME_SIZE: "40"
NUM_SOCKETS: "1"
NUM_CORES: "2"
MEMORY_MIB: "4096"
+17
View File
@@ -0,0 +1,17 @@
# helm install ca autoscaler/cluster-autoscaler --version 9.59.0 -n default -f values.yaml
# must be installed in the same namespace as the <cluster>-kubeconfig secret
cloudProvider: clusterapi
clusterAPIMode: kubeconfig-incluster # workload via kubeconfig, management in-cluster
clusterAPIKubeconfigSecret: lab1-kubeconfig
autoDiscovery:
clusterName: lab1
namespace: default
image:
tag: v1.35.2 # chart default appVersion is 1.35.0
extraArgs:
expander: least-waste
scale-down-unneeded-time: 5m
scale-down-delay-after-add: 5m
scale-down-utilization-threshold: "0.5"
balance-similar-node-groups: true
v: 4
@@ -0,0 +1,8 @@
{
"disk_format": "raw",
"kubernetes_semver": "v1.35.9",
"kubernetes_series": "v1.35",
"kubernetes_deb_version": "1.35.9-1.1",
"iso_file": "local:iso/ubuntu-24.04.4-live-server-amd64.iso",
"iso_url": ""
}
+18
View File
@@ -0,0 +1,18 @@
# apply to the workload cluster; scale replicas to trigger scale-up (≈2 pods per 4 vCPU / 8 GiB worker)
apiVersion: apps/v1
kind: Deployment
metadata:
name: inflate
spec:
replicas: 0
selector:
matchLabels: { app: inflate }
template:
metadata:
labels: { app: inflate }
spec:
containers:
- name: pause
image: registry.k8s.io/pause:3.10
resources:
requests: { cpu: "1500m", memory: "2Gi" }
+22
View File
@@ -0,0 +1,22 @@
# Decisions
Every direction chosen, rejected or reversed, newest at the bottom. Rows are never rewritten:
a reversal is a new row, and the old row's status becomes `superseded by #N`. Status is
`active`, `proposed` (not yet confirmed by lab work) or `superseded by #N`.
Rows 1–9 were back-filled on 2026-09-26 from the README, `versions.md`, the phase docs and the
image-builder fixes log; their dates are when the decision was recorded, not necessarily made.
| # | Date | Decision | Alternatives considered | Why | Status |
| --- | --- | --- | --- | --- | --- |
| 1 | 2026-09-24 | Pin CAPI core to 1.12.x ([versions.md](versions.md)) | latest CAPI 1.14.x | CAPMOX v0.9.x is only tested against CAPI 1.11/1.12; caps workload Kubernetes and cluster-autoscaler at 1.35 | active |
| 2 | 2026-09-24 | Management cluster is k3s on a Proxmox VM | kind on a workstation | CAPI controllers and the autoscaler must run permanently | active |
| 3 | 2026-09-24 | Node images built with kubernetes-sigs/image-builder (Ubuntu 24.04, Proxmox builder) | — | upstream-maintained CAPI images | active |
| 4 | 2026-09-24 | image-builder overrides go in a `PACKER_VAR_FILES` JSON | `PACKER_FLAGS` | flags were silently lost when `PACKER_FLAGS` was re-exported ([fixes log #4](image-builder-proxmox.md#fixes-log)) | active |
| 5 | 2026-09-24 | Cilium installed by Helm with `ipam.mode=kubernetes`, pod CIDR `10.244.0.0/16` | ClusterResourceSet CNI flavors; pod CIDR `192.168.0.0/16` | keeps the CNI version under our control; `192.168.0.0/16` overlaps the lab LAN | active |
| 6 | 2026-09-24 | Scale-from-zero via `capacity.cluster-autoscaler.kubernetes.io/*` annotations on the MachineDeployment | infrastructure provider publishing `status.capacity` | CAPMOX does not publish `status.capacity` on `ProxmoxMachineTemplate` | active |
| 7 | 2026-09-24 | Template only on `pv1-1`, `ALLOWED_NODES=[pv1-1]` | template on every node; shared storage | `local-lvm` is node-local and there is no shared storage yet | active |
| 8 | 2026-09-26 | Phase 2: spike CAPLV first; own provider if insufficient; KubeVirt as fallback ([phase-2](phase-2-kvm-provider.md#suggested-approach)) | CAPK + KubeVirt, Metal3, own provider from the start | CAPLV shares CAPI 1.12 with CAPMOX and fits plain libvirt hosts directly | proposed |
| 9 | 2026-09-26 | Phases 2 and 3 are independent and can be done in either order | strict sequence | no technical dependency between the KVM provider and hosted control planes | active |
| 10 | 2026-09-26 | Keep project history in the repo: plans, execution logs, session notes, decisions, prompts, reviews, changelog ([session](sessions/2026-09-26-2355-history-conventions.md)) | chat history only; raw transcripts committed | research repo, reasoning must outlive sessions; transcripts are noisy and may contain secrets | active |
| 11 | 2026-09-27 | Cluster inspection by agents only via read-only ServiceAccount kubeconfigs minted by `.claude/cluster-inspector/make-kubeconfig.sh` (short-lived TokenRequest tokens, gitignored) | admin kubeconfig + permission allowlist; long-lived SA token Secret; RBAC under `configs/` | RBAC (`view`, no Secrets) enforces read-only regardless of which kubectl verb an agent runs — also closes the `kubectl get secret` gap from the history-conventions session; short-lived tokens limit leak impact | active |
+85
View File
@@ -0,0 +1,85 @@
# Node VM template with image-builder (Proxmox)
Result: Proxmox template `ubuntu-2404-kube-v1.35.9` on node `pv1-1` — Ubuntu 24.04.4, containerd,
kubeadm/kubelet/kubectl v1.35.9, pre-pulled control-plane images, cloud-init, qemu-guest-agent.
Its VMID is `TEMPLATE_VMID` for CAPMOX.
## Build host prerequisites (Ubuntu 24.04)
```bash
sudo apt install -y make git curl unzip ca-certificates openssl openssh-client \
python3 python3-venv python3-pip
python3 -m venv ~/ib-venv && source ~/ib-venv/bin/activate
pip install ansible-core==2.18.18 # avoids PEP 668 / --break-system-packages
git clone https://github.com/kubernetes-sigs/image-builder.git ~/image-builder
cd ~/image-builder/images/capi && git checkout v0.1.55
make deps-proxmox # python/ansible/packer 1.9.5 checks + packer init
```
## Build
Overrides live in a var file ([configs/image-builder/proxmox-lab.json.example](../configs/image-builder/proxmox-lab.json.example)),
passed via `PACKER_VAR_FILES` (last on the packer command line → highest precedence).
```bash
export PROXMOX_URL="https://192.168.0.8:8006/api2/json"
export PROXMOX_USERNAME='capmox@pve!capi'
export PROXMOX_TOKEN="<secret>"
export PROXMOX_NODE="pv1-1" # PVE node *name* (pvesh get /nodes), not IP; template lands here
export PROXMOX_ISO_POOL="local" # storage with content 'iso' (pvesm status --content iso)
export PROXMOX_STORAGE_POOL="local-lvm" # storage with content 'images' (pvesm status --content images)
export PROXMOX_BRIDGE="vmbr0"
export PROXMOX_VLAN="4"
export PACKER_VAR_FILES=~/proxmox-lab.json
unset PACKER_FLAGS ISO_FILE
export SSH_PASSWORD=labpass # optional: ssh builder@<vm> while provisioning runs
ON_ERROR_ASK=1 PACKER_LOG=1 PACKER_LOG_PATH=packer.log make build-proxmox-ubuntu-2404
```
The build VM (VLAN 4) needs DHCP and must reach the Packer HTTP server on the build host
(ports 8000–9000) to fetch the autoinstall `user-data`.
## What the build does
1. `set-ssh-password` renders the autoinstall `user-data` with a hashed password for user `builder`.
2. Packer creates VM `packer-<uuid>` (2 vCPU, 2 GB, 20 GB), attaches the ISO, serves `http/24.04/`.
3. After `boot_wait` it types the GRUB boot command over VNC: `autoinstall ds=nocloud-net;s=http://<host>:<port>/24.04/`.
4. Subiquity installs Ubuntu and reboots; Packer waits for SSH (up to 2h), IP via guest agent.
5. Provisioners: wait for cloud-init → ansible `firstboot.yml` → reboot → ansible `node.yml`
(containerd, k8s binaries, images, sysctl, cloud-init config) → goss tests → lock `builder`, shutdown.
6. VM converted to template `ubuntu-2404-kube-v1.35.9`.
Observe it in the PVE noVNC console; `DEBUG=1` steps through each Packer stage; `make -n` prints the
exact `packer build` command. Typical duration 20–40 min.
## Fixes log
| # | Symptom | Root cause | Fix |
| --- | --- | --- | --- |
| 1 | `POST .../storage/local/upload: write: broken pipe` | pveproxy dropped the 3.17 GiB upload mid-stream — missing `Datastore.AllocateTemplate` (token had only `PVEVMAdmin`) or no spool space in `/var/tmp` (exact cause not pinned down) | `PVEDatastoreAdmin` on `/storage`, check `df -h /var/tmp`; then avoid re-uploads with `iso_file` |
| 2 | `net0.bridge: invalid bridge ID 'vmbr0.4@vmbr0'` | `PROXMOX_BRIDGE` set to the `ip link` name of a VLAN sub-interface; PVE wants a bridge name + separate tag | `PROXMOX_BRIDGE=vmbr0`, `PROXMOX_VLAN=4` (→ `bridge=vmbr0,tag=4`) |
| 3 | `403 Permission check failed (/sdn/zones/localnetwork/vmbr0.4, SDN.Use)` | since PVE 8 every Linux bridge is an object in SDN zone `localnetwork`; `PVEVMAdmin` lacks `SDN.Use` | `PVESDNUser` on `/sdn/zones/localnetwork` |
| 4 | `unsupported format 'qcow2' at .../LvmThinPlugin.pm line 102` | image-builder defaults `disk_format` to qcow2; LVM-thin is raw-only. `--var disk_format=raw` in `PACKER_FLAGS` was lost when `PACKER_FLAGS` was re-exported | `"disk_format": "raw"` in the `PACKER_VAR_FILES` JSON |
| 5 | `one of iso_file, iso_url, or ... cd_content must be specified for boot_iso` | packer-plugin-proxmox v1.2.4 requires **exactly one** ISO source; `ubuntu-2404.json` sets `iso_file` from `$ISO_FILE` *and* a hardcoded `iso_url` → count 2. Misleading error; README tip is broken (worth an upstream issue) | set `iso_file` and `"iso_url": ""` in the var file, unset `ISO_FILE` |
Notes carried into CAPMOX (phase 1): same ACL set, `vlan: 4` on the network device, no
`format: qcow2` in `ProxmoxMachineTemplate`s.
## Smoke test of the template
The template has no usable login (`builder` is locked) — access only via cloud-init (NoCloud,
which the PVE Cloud-Init drive provides):
```bash
TPL=<template vmid>; ID=9001
qm clone $TPL $ID --name smoke-k8s --full 1 --storage local-lvm
qm set $ID --ide2 local-lvm:cloudinit
qm set $ID --ciuser jan --sshkeys /root/jan.pub --ipconfig0 ip=dhcp
qm set $ID --net0 virtio,bridge=vmbr0,tag=4
qm start $ID && qm guest cmd $ID network-get-interfaces
# inside: cloud-init status --long; kubeadm version -o short; crictl images
qm stop $ID && qm destroy $ID --purge
```
kubelet crash-looping before `kubeadm init/join` is expected.
+173
View File
@@ -0,0 +1,173 @@
# Phase 1 — Proxmox VMs via Cluster API + cluster-autoscaler
Goal: a manually installed k3s management cluster runs CAPI + CAPMOX and creates workload
cluster `lab1` as Proxmox VMs; cluster-autoscaler scales its workers 0–5, including scale-from-zero.
No custom code in this phase — only assembling upstream components.
Versions: see [versions.md](versions.md). Template build: see [image-builder-proxmox.md](image-builder-proxmox.md).
## Topology
| Role | Where | Size |
| --- | --- | --- |
| Management cluster | 1 VM on Proxmox, k3s v1.35.8+k3s1 | 2 vCPU, 4 GB RAM, 30 GB |
| `lab1` control plane | 1 VM, VIP via kube-vip | 2 vCPU, 4 GB RAM |
| `lab1` workers | MachineDeployment `lab1-workers`, min 0 / max 5 | 4 vCPU, 8 GB RAM |
## Checklist
- [x] Step 0 — Proxmox prep (API token, ACLs, bridge/VLAN, IP ranges)
- [x] Step 1 — VM template `ubuntu-2404-kube-v1.35.9` built and smoke-tested
- [ ] Step 2 — management cluster + `clusterctl init`
- [ ] Step 3 — workload cluster `lab1`
- [ ] Step 4 — CNI (Cilium)
- [ ] Step 5 — MachineDeployment autoscaling annotations
- [ ] Step 6 — cluster-autoscaler
- [ ] Step 7 — scale tests
## Step 0 — Proxmox prep
```bash
pveum user add capmox@pve
pveum user token add capmox@pve capi -privsep 0 # token inherits user ACLs
pveum aclmod / -user capmox@pve -role PVEVMAdmin
pveum aclmod / -user capmox@pve -role PVEAuditor # Sys.Audit etc.
pveum aclmod /storage -user capmox@pve -role PVEDatastoreAdmin # ISO + cloud-init ISO upload, disks
pveum aclmod /sdn/zones/localnetwork -user capmox@pve -role PVESDNUser # NIC on any local bridge (PVE 8+)
```
Least-privilege variant (dedicated pools, specific bridge/VLAN): CAPMOX `docs/advanced-setups.md`.
Network: `vmbr0` is VLAN-aware, VMs live in VLAN 4. Reserve a node IP range outside DHCP and one VIP
for the control plane endpoint in the same subnet.
Storage: `local-lvm` (LVM-thin) → disks must be **raw**, never qcow2. Cloning to another PVE node
requires shared storage; with local storage keep `ALLOWED_NODES` to the node holding the template.
## Step 2 — management cluster
```bash
curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.35.8+k3s1 sh -s - --disable traefik
mkdir -p ~/.kube && sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config && sudo chown $USER ~/.kube/config
curl -L -o clusterctl https://github.com/kubernetes-sigs/cluster-api/releases/download/v1.12.11/clusterctl-linux-amd64
sudo install -m 0755 clusterctl /usr/local/bin/clusterctl
```
`~/.cluster-api/clusterctl.yaml` — start from [configs/capi/clusterctl.yaml.example](../configs/capi/clusterctl.yaml.example).
```bash
clusterctl init \
--core cluster-api:v1.12.11 \
--bootstrap kubeadm:v1.12.11 \
--control-plane kubeadm:v1.12.11 \
--infrastructure proxmox:v0.9.1 \
--ipam in-cluster:v1.1.1
```
Always pin versions explicitly — without them clusterctl installs the newest core (1.14.x) and
breaks the tested CAPMOX combination.
## Step 3 — workload cluster `lab1`
```bash
clusterctl generate cluster lab1 \
--infrastructure proxmox:v0.9.1 \
--kubernetes-version v1.35.9 \
--control-plane-machine-count 1 \
--worker-machine-count 1 > lab1.yaml
```
Edit `lab1.yaml` before applying:
1. **Pod CIDR** — `Cluster.spec.clusterNetwork.pods.cidrBlocks` defaults to `192.168.0.0/16`, which
overlaps the lab network → use e.g. `10.244.0.0/16`.
2. **kube-vip** — bump `ghcr.io/kube-vip/kube-vip:v0.7.1` → `v1.2.4`; keep `kube-vip-prepare.sh`
(handles `super-admin.conf` for kubeadm ≥ 1.29). Fall back to the template's version if the VIP flakes.
3. **Disk format** — delete `format: "qcow2"` from both `ProxmoxMachineTemplate`s (LVM-thin).
4. **Network** — `bridge: vmbr0` + `vlan: 4` on the network device.
5. **Worker size** — `ProxmoxMachineTemplate/lab1-worker`: `numSockets: 1`, `numCores: 4`, `memoryMiB: 8192`.
6. **Replicas** — remove `spec.replicas` from `MachineDeployment/lab1-workers`; the autoscaler owns it.
```bash
kubectl apply -f lab1.yaml
clusterctl describe cluster lab1 --show-conditions all
kubectl get proxmoxmachines,machines -w
clusterctl get kubeconfig lab1 > lab1.kubeconfig
```
Node IPs come from the IPAM pool (`kubectl get ipaddressclaims,ipaddresses`). providerID is set by
the CAPMOX template as `proxmox://{{ ds.meta_data.instance_id }}` — don't touch it, the autoscaler
matches Nodes to Machines by it.
## Step 4 — Cilium
```bash
export KUBECONFIG=$PWD/lab1.kubeconfig
helm repo add cilium https://helm.cilium.io && helm repo update
helm install cilium cilium/cilium --version 1.20.2 -n kube-system --set ipam.mode=kubernetes
```
`ipam.mode=kubernetes` uses the pod CIDR from `clusterNetwork` instead of Cilium's `10.0.0.0/8` default.
## Step 5 — autoscaling annotations
```yaml
metadata:
name: lab1-workers
annotations:
cluster.x-k8s.io/cluster-api-autoscaler-node-group-min-size: "0"
cluster.x-k8s.io/cluster-api-autoscaler-node-group-max-size: "5"
capacity.cluster-autoscaler.kubernetes.io/cpu: "4" # numSockets × numCores
capacity.cluster-autoscaler.kubernetes.io/memory: "8Gi"
capacity.cluster-autoscaler.kubernetes.io/ephemeral-disk: "40Gi"
capacity.cluster-autoscaler.kubernetes.io/maximum-pods: "110"
```
Capacity annotations are required for scale-from-zero because CAPMOX does not publish
`status.capacity`. They must match the `ProxmoxMachineTemplate`; add `.../labels` and `.../taints`
annotations if pods target the group by selector/toleration.
## Step 6 — cluster-autoscaler
Install into the **same namespace as the `lab1-kubeconfig` secret** (here `default`), values in
[configs/cluster-autoscaler/values.yaml](../configs/cluster-autoscaler/values.yaml):
```bash
helm repo add autoscaler https://kubernetes.github.io/autoscaler && helm repo update
helm install ca autoscaler/cluster-autoscaler --version 9.59.0 -n default \
-f configs/cluster-autoscaler/values.yaml
```
The log must show node group `MachineDeployment/default/lab1-workers` with min 0 / max 5.
## Step 7 — scale tests
Workload: [configs/test/inflate.yaml](../configs/test/inflate.yaml) (pause pods, 1500m / 2Gi each).
- [ ] Scale-up: `kubectl scale deploy/inflate --replicas=6` → workers 1 → 3; record pending → Running time.
- [ ] Scale-down: `--replicas=0` → after `scale-down-unneeded-time` (5m) Machines and VMs disappear.
- [ ] Scale-to-zero: last worker goes away (watch CoreDNS — see troubleshooting).
- [ ] Scale-from-zero: `--replicas=2` → CA provisions from capacity annotations.
`kubectl describe pod <pending>` shows `TriggeredScaleUp` / `NotTriggerScaleUp` events — fastest diagnostic.
## Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| CA sees no node group | missing min/max annotations or wrong `autoDiscovery` | check MD annotations, clusterName/namespace in values |
| CA never scales down | Node `providerID` ≠ Machine `providerID` | compare `kubectl get nodes -o custom-columns=N:.metadata.name,P:.spec.providerID` with `kubectl get machines` |
| MD jumps back to old count | `spec.replicas` in manifest re-applied | remove `replicas` |
| last worker won't go to 0 | kube-system pods without PDB (`skip-nodes-with-system-pods=true`) | PDB for CoreDNS or `skip-nodes-with-system-pods: false` |
| scale-from-zero does nothing | capacity annotations missing/wrong | fix cpu/memory/maximum-pods |
| clone fails `unsupported format 'qcow2'` | qcow2 on LVM-thin | remove `format` |
| ProxmoxMachine stuck without IP | IPAM pool exhausted | `kubectl get ipaddressclaims`, widen range |
| `SDN.Use` 403 | missing `PVESDNUser` | ACL on `/sdn/zones/localnetwork` |
```bash
kubectl -n capmox-system logs deploy/capmox-controller-manager -f
kubectl -n capi-system logs deploy/capi-controller-manager -f
ssh <user>@<node-ip> 'cloud-init status --long; journalctl -u kubelet -n 100'
```
+62
View File
@@ -0,0 +1,62 @@
# Phase 2 — CAPI provider for vanilla KVM/libvirt hosts
Goal: autoscaled worker VMs on the Ubuntu 24.04 KVM/libvirt hypervisors, managed by the same
management cluster as phase 1. Status: **design / evaluation**.
## Problem
There is no mature, SIG-maintained CAPI infrastructure provider for plain libvirt. Proxmox does not
use libvirt (it drives QEMU via `qm`), so one libvirt provider cannot cover both hypervisor types.
## Options
| Option | How it works | Pros | Cons |
| --- | --- | --- | --- |
| **A. CAPLV** ([joshuagrisham/cluster-api-provider-libvirt](https://github.com/joshuagrisham/cluster-api-provider-libvirt), v0.2.1, CAPI v1.12.1) | talks libvirt RPC via pure-Go `digitalocean/go-libvirt` | same CAPI core as CAPMOX; direct fit for our hosts | young, single maintainer; feature gaps unknown (IPAM, placement, capacity) |
| **B. CAPK + KubeVirt** (CAPK v0.11.2 on CAPI v1.11.4, KubeVirt v1.9.0) | Ubuntu hosts become a Kubernetes "infra cluster" running KubeVirt; CAPK creates `VirtualMachine`s there | kubernetes-sigs project, mature, live migration, CDI for images | replaces plain libvirt management with a whole k8s + KubeVirt stack on the hypervisors; nested networking design (bridge/Multus) |
| **C. Metal3** (CAPM3 v1.14.1) + sushy-tools | VMs pre-defined in libvirt, exposed as "bare metal" via Redfish emulator, provisioned by Ironic | production-grade bare-metal flow | Ironic is heavy; VM pool is fixed → not real elasticity |
| **D. Own provider** | kubebuilder project, `LibvirtCluster/LibvirtMachine/LibvirtMachineTemplate` | full control, best learning value; can add multi-backend and `status.capacity` | we own maintenance |
## Suggested approach
1. **Spike A (≈ 1 week):** deploy CAPLV next to CAPMOX (both on CAPI 1.12), one MachineDeployment
on one Ubuntu host. Check: cloud-init/bootstrap handling, IP assignment (DHCP vs CAPI IPAM),
`providerID` format, delete/cleanup of volumes, behaviour under cluster-autoscaler.
2. If A is insufficient → **D**, reusing CAPLV's patterns, or contribute upstream.
3. Keep **B** as the alternative if we're willing to turn the Ubuntu hosts into a KubeVirt cluster
(it may pay off together with phase 3).
## Own provider sketch (option D)
Reconcile of `LibvirtMachine`:
1. Wait for `Machine.spec.bootstrap.dataSecretName`.
2. Pick a host (placement: one MachineDeployment per host, or least-loaded by free memory).
3. Create a qcow2 overlay volume on the base image (backing file) in the host's storage pool.
4. Build a NoCloud ISO from the bootstrap secret (+ network-config from CAPI IPAM `IPAddressClaim`).
5. `virDomainDefineXML` + start; wait for IP (guest agent / DHCP leases).
6. Set `spec.providerID = libvirt://<host>/<domain-uuid>`, `status.addresses`, `ready`.
7. Finalizer: destroy domain, delete overlay + ISO.
`LibvirtMachineTemplate.status.capacity` (cpu/memory) → scale-from-zero without annotations.
Connection: `qemu+ssh://` or `qemu+tls://` per host, credentials in a Secret.
## Mixing providers in one workload cluster
`Cluster.spec.infrastructureRef` points to **one** InfraCluster kind, and most providers' machine
reconcilers look up *their own* InfraCluster kind through it — a `ProxmoxMachine` in a cluster whose
InfraCluster is a `LibvirtCluster` won't reconcile. Choices:
- **Separate workload clusters per provider** — simplest, no hacks.
- **Tolerant own provider** — our `LibvirtMachine` works regardless of the InfraCluster kind, so a
CAPMOX-based cluster can also get libvirt worker MachineDeployments.
- **Multi-backend provider** — one `HypervisorCluster` kind with Proxmox API + libvirt backends.
cluster-autoscaler is indifferent: it scales any MachineDeployment carrying the min/max annotations,
whatever its infrastructure kind. The priority expander can prefer one hypervisor pool over another.
## Exit criteria
- [ ] Worker MachineDeployment on an Ubuntu KVM host joins a cluster managed from the phase-1 mgmt cluster.
- [ ] cluster-autoscaler scales it 0 → N → 0.
- [ ] VM and volumes fully cleaned up on scale-down.
+97
View File
@@ -0,0 +1,97 @@
# Phase 3 — Hosted control planes with Kamaji ("poor man's KaaS")
Goal: create a tenant Kubernetes cluster from one manifest. Its control plane runs as pods in the
management cluster (Kamaji), and its workers are autoscaled Proxmox (later also KVM) VMs.
Status: **design notes**.
## Why Kamaji
With kubeadm control planes every tenant costs ≥ 1 VM (3 for HA) just for apiserver/etcd. Kamaji
runs each tenant control plane as a Deployment in the management cluster, backed by a shared
datastore. That cuts the control plane to a few hundred MB of RAM, gives it seconds to start, and
makes upgrades a rolling Deployment update.
## Architecture
```mermaid
flowchart LR
subgraph MGMT[management cluster - 3 nodes]
CAPI[CAPI core + CAPMOX]
KCP[Kamaji CP provider]
KAM[Kamaji controller]
DS[(kamaji-etcd datastore)]
LB[MetalLB pool on VLAN 4]
subgraph T1[ns tenant-a]
TCP1[TenantControlPlane pods<br/>apiserver, ctrl-mgr, scheduler]
CA1[cluster-autoscaler tenant-a]
end
end
PVE[Proxmox VE]
W1[tenant-a worker VMs]
KCP --> KAM --> TCP1
TCP1 --> DS
LB -. VIP :6443 .- TCP1
CAPI -- MachineDeployment --> PVE --> W1
W1 -- kubelet join via VIP --> TCP1
CA1 -- scales MD --> CAPI
```
CAPI object wiring per tenant:
- `Cluster.spec.controlPlaneRef` → `KamajiControlPlane`, which creates the Kamaji `TenantControlPlane`.
- `Cluster.spec.infrastructureRef` → `ProxmoxCluster` with **`externalManagedControlPlane: true`**,
so the Kamaji provider patches the control plane endpoint. The field exists in CAPMOX v1alpha2.
- Workers: a regular `MachineDeployment` + `KubeadmConfigTemplate` + `ProxmoxMachineTemplate`, with
autoscaler annotations as in phase 1.
- Konnectivity handles apiserver → kubelet traffic, because the control plane isn't on the worker network.
References: [Kamaji + Proxmox infra provider docs](https://kamaji.clastix.io/cluster-api/proxmox-infra-provider/),
CLASTIX Helm chart `capi-kamaji-proxmox` (0.0.4) as a wiring example.
## Management cluster changes vs. phase 1
| Area | Phase 1 | Phase 3 |
| --- | --- | --- |
| Nodes | 1× k3s | 3 nodes: tenant control planes and their datastore live here |
| LoadBalancer | none needed (kube-vip in workload CP) | MetalLB (or kube-vip cloud provider) with an IP pool on VLAN 4, one VIP per tenant |
| Datastore | — | `kamaji-etcd` chart 0.18.1 (etcd 3.6.12); alternatives: PostgreSQL/MySQL via kine |
| Storage | — | CSI for etcd PVCs |
k3s' built-in servicelb (klipper) binds host ports, so it can't hand out many `:6443` endpoints. Use MetalLB.
## Autoscaling per tenant
A cluster-autoscaler instance in `clusterapi` mode serves **one** workload cluster (one kubeconfig).
So each tenant gets its own CA release in its namespace in the management cluster
(`kubeconfig-incluster`, secret `<tenant>-kubeconfig`). Automating that is our first real Go code:
- **tenant-operator** (small controller in this repo): watches `Cluster` objects labelled
`kaas.lab/autoscaling=enabled`, renders a per-tenant cluster-autoscaler Deployment + RBAC, and
cleans up on delete. The alternative is CAAPH (cluster-api-addon-provider-helm), but that installs
into the *workload* cluster, and we want CA in the management cluster.
## Self-service UX
- `ClusterClass` `lab-tenant` with variables: Kubernetes version, worker size, min/max workers, VLAN.
- A tenant cluster is one `Cluster` with `spec.topology` referencing the class.
- A namespace per tenant with RBAC limited to its own `Cluster`; the kubeconfig is handed out from the
`<tenant>-kubeconfig` secret.
## Open questions and risks
- **CAPI version skew:** Kamaji CP provider v0.21.0 is built against CAPI **v1.13.4**, while CAPMOX
v0.9.x is tested on **v1.12**. All providers share one CAPI core, so either find a Kamaji CP
provider release built on 1.12 or wait for CAPMOX to support 1.13+. Check each provider's
`metadata.yaml` contract before installing.
- **Kamaji OSS release model:** OSS publishes edge releases only (latest tag `edge-26.2.5`, Helm chart
`0.0.0+latest`). Stable versioned builds are commercial (CLASTIX), so pin by edge tag and image digest.
- Tenant Kubernetes version range is bounded by the Kamaji release and by our node images. We need
one image-builder template per supported minor.
- Backup and restore of the shared datastore (etcd snapshots) becomes critical: it holds every tenant.
## Exit criteria
- [ ] Tenant cluster created from a single `Cluster` + `ClusterClass`, control plane as pods in mgmt.
- [ ] Workers join via Konnectivity, and CNI works.
- [ ] Per-tenant cluster-autoscaler deployed automatically, scale 0 → N → 0 works.
- [ ] Deleting the `Cluster` removes the control plane, VMs, VIP and autoscaler.
View File
View File
View File
View File
@@ -0,0 +1,51 @@
# Session: which conventions to adopt from egress-proxies-operator
**Created:** 2026-09-26 23:55
## Question
> look into project in /Users/jan.novak/srv/go/egress-proxies-operator and suggest which rules i
> should copy over into this project
>
> this project is sort of research/experimentation project, so i want to keep history of
> conversations, decisions, plans, etc.
## What was checked
`egress-proxies-operator`: `CLAUDE.md`, `.claude/settings.json` + `settings.local.json`,
`.claude/commands/{ship,release}.md`, `.claude/agents/*`, `AGENTS.md`, and samples from
`docs/plans/`, `docs/plans-executions/`, `docs/prompts/`, `docs/reviews/`, `CHANGELOG.md`.
## Findings
Adopted, adapted to a docs/lab repo:
- Plans in `docs/plans/` with the "commit the plan first, on its own" ordering rule.
- Execution logs in `docs/plans-executions/`, especially "exact commands with one line of
context, deviations called out", which suits lab runbook work better than Go code.
- `docs/prompts/`, `docs/reviews/`, top-inserted `CHANGELOG.md` with real `date` timestamps.
- Gitea + `tea pr create` MR flow, but only for phase-3 Go code; docs go straight to `main`.
- `/ship` command; a small read-only permission allowlist (`kubectl get/describe/logs`,
`clusterctl describe`, `helm list`, git read commands).
Not adopted: Go and testing conventions, CI/CD, kubebuilder gotchas, `AGENTS.md`, the two
operator reviewer agents (candidates for phase 3), `/release` (no tags or images here),
`.junie/.codex/.agents` duplicates, and the broad `kubectl *` / `curl *` permissions.
New, not in the operator repo, because decisions there are buried in plan context sections:
- `docs/decisions.md`: one table with alternatives and status, back-filled with 9 rows.
- `docs/sessions/`: summaries of conversations that did not produce a plan (this file is the first).
## Open questions
- ~~Whether `kubectl get *` in the allowlist is too broad: `kubectl get secret -o yaml` would
print the `lab1-kubeconfig` secret without a prompt.~~ Resolved by
[decision #11](../decisions.md): a parallel session introduced read-only `cluster-inspector`
kubeconfigs, so the `kubectl` / `clusterctl` / `helm` allowlist entries were dropped from
`.claude/settings.json` again.
## Follow-ups
- [Decision #10](../decisions.md)
- `CLAUDE.md` sections "Project history" and "Git"
+44
View File
@@ -0,0 +1,44 @@
# Versions
Verified 2026-09-24 (phase 1) and 2026-09-26 (phase 2/3 candidates) from the Go module proxy,
`dl.k8s.io`, `registry.k8s.io`, upstream Helm indexes and git tags. Re-verify before every deploy.
## Phase 1 — pinned
| Component | Pinned | Latest available | Why pinned there |
| --- | --- | --- | --- |
| Cluster API core, kubeadm bootstrap + control plane, clusterctl | **v1.12.11** | v1.14.2 | CAPMOX v0.9 compatibility table lists CAPI v1.11 / v1.12 only; built against v1.12.11 |
| CAPMOX (ionos-cloud/cluster-api-provider-proxmox) | **v0.9.1** | v0.9.1 | API `v1alpha2`, contract `v1beta2` |
| IPAM provider in-cluster | **v1.1.1** | v1.1.1 | CAPMOX v0.9 requires ≥ 1.1.0 |
| Workload Kubernetes | **v1.35.9** | v1.37.1 | CAPI 1.12 supports workload clusters up to 1.35 |
| Management cluster (k3s) | **v1.35.8+k3s1** | v1.37.0+k3s1 | CAPI 1.12 supports management clusters 1.31–1.35 |
| cluster-autoscaler | **v1.35.2** | v1.36.1 | CA minor must match workload Kubernetes minor |
| cluster-autoscaler Helm chart | **9.59.0** | 9.59.0 | default appVersion 1.35.0 → override `image.tag` |
| Cilium | **1.20.2** | 1.20.2 | |
| kube-vip | **v1.2.4** | v1.2.4 | CAPMOX template still ships v0.7.1 |
| image-builder | **v0.1.55** | v0.1.55 | |
| Packer (installed by image-builder) | **1.9.5** | — | last MPL-licensed release, pinned by image-builder |
| packer-plugin-proxmox | **v1.2.4** | v1.2.4 | pulled by `packer init` (`>= 1.1.2`) |
| ansible-core (image-builder) | **2.18.18** | — | pinned by image-builder `hack/utils.sh` |
| Node OS | Ubuntu 24.04.4 | — | ISO sha256 `e907d92e…a138433` |
### Upgrade path
When CAPMOX adds CAPI 1.13/1.14 to its compatibility table: bump CAPI core → k3s mgmt to 1.36+ →
rebuild template with Kubernetes 1.36/1.37 → cluster-autoscaler to the matching minor.
## Phase 2 / 3 — candidates (not pinned yet)
| Component | Latest | Built against CAPI | Notes |
| --- | --- | --- | --- |
| CAPLV (joshuagrisham/cluster-api-provider-libvirt) | v0.2.1 (2026-02) | v1.12.1 | pure-Go `digitalocean/go-libvirt`, single maintainer |
| CAPK (cluster-api-provider-kubevirt) | v0.11.2 (2026-02) | v1.11.4 | needs KubeVirt on the hypervisors |
| KubeVirt | v1.9.0 (2026-07) | — | |
| CAPM3 (Metal3) | v1.14.1 (2026-09) | — | needs Ironic + BMO + sushy-tools for VMs |
| cluster-api-control-plane-provider-kamaji | v0.21.0 (2026-09-16) | **v1.13.4** | ⚠ newer CAPI than CAPMOX v0.9 is tested with |
| Kamaji (OSS) | `edge-26.2.5`, Helm chart `0.0.0+latest` | — | OSS ships edge releases only; last semver tag v1.0.0 (2024) |
| kamaji-etcd Helm chart | 0.18.1 (etcd 3.6.12) | — | datastore for hosted control planes |
| CLASTIX `capi-kamaji-proxmox` chart | 0.0.4 (2025-10) | — | reference for Kamaji + CAPMOX wiring |
The CAPI version skew between CAPMOX (1.12) and the Kamaji CP provider (1.13) is the main open
compatibility question for phase 3 — all providers in one management cluster share one CAPI core.