Author SHA1 Message Date
kacerrandClaude Opus 5.5 ff94fe70bb proxmox: captured API fixtures and host facts from pv1-1 (PVE 9.2.20)
Phase 0 of the proxmox provider plan. 55 fixtures under
internal/provider/proxmox/testdata, each the status, reason phrase and
raw body of a real response from the user's host, and the execution log
answering the plan's facts F1 to F23.

Recon ran a clone of the Debian 13 template through the whole path the
provider will take, twice: untagged and on VLAN 4. On both, the NoCloud
seed on ide2 installed the guest agent and an address came back about 50
seconds after start. It also found two bugs in the plan: a VM being
cloned has no name in /cluster/resources, so name-first lookups would read
it as gone, and a crash-retry would clone a duplicate. Those and eight
smaller corrections are listed for the spec.

Decisions made with the user: seeds go to a dedicated proxy-seeds
storage where the token can delete them; proxies land on a VLAN set in
config (vlanTag); the API URL is https://192.168.0.8:8006, the address in
the node certificate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 08:22:41 +00:00
kacerrandClaude Opus 5 2167819157 docs: retarget the proxmox plan at Proxmox VE 9.2
The host is 9.2.20, not 8.x. Three corrections that would have failed
at runtime:

- VM.Monitor was removed in PVE 9.0, so it is dropped from the role and
  the agent read rests on VM.GuestAgent.Audit alone.
- The GC-assist tag becomes proxy_operator: current tag validation takes
  only lowercase letters, digits and underscore, so proxy-operator would
  have been rejected on every Create. The description mark keeps its
  hyphenated keys; it is free text, not a tag.
- HTTP/2 is documented as absent from pveproxy on every release, so the
  client disabling it is a fact rather than a guess.

Two new Phase 0 facts: F22 for the 9.2 unified authentication-failure
message, which invalidates any 401 text remembered from 8.x, and F23 for
the node /tmp tmpfs move. F12 records that the upload endpoint takes
iso, vztmpl or import on 9.x and still refuses snippets, which is what
keeps the seed ISO route the only API-only path for user-data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 15:49:14 +00:00
kacerrandClaude Fable 5.1 708989c40f docs: plan the proxmox provider
Eleven self-contained phases (host recon and fixtures, spec, config and
skeleton, nocloud/keyedlock extraction from kvm2, HTTP client and fake,
Get/List, Create, Delete/Checker, test sweep and integration test,
deployment and docs, review and MR), with the Proxmox host setup
checklist and the backend facts Phase 0 must verify on 192.168.2.8.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 15:02:53 +00:00
kacerrandClaude Opus 5 8fa9e8aad9 Housekeeping: review docs into docs/, refreshed agent guide and settings
Four unrelated working-tree changes, none of them touching operator
behaviour:

- review/*.md -> docs/reviews/2026-09-17-kvm2-provider-review/, byte for
  byte, so the kvm2 review sits with the rest of the docs under a name
  that says which review it was.
- AGENTS.md: replaced the generic kubebuilder scaffold guide with what is
  actually true of this repo — the real component layout, the lint/test
  commands that work here, and the behavioural gotchas (replace-on-change
  hashing, the cloud-init Secret label, in-memory leases).
- .claude/settings.json: the allow list still carried /Users/jan.novak
  paths from the macOS checkout; re-pointed at /home/novakj and collapsed
  the one-off entries into prefixes.
- config/samples/proxy_kvm2_crawl_only_one.yaml: a one-VM cut of the crawl
  pool, the rest of the members left commented out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 08:12:27 +00:00
kacerrandClaude Opus 5 1fa3e55b9c docs: start docs/operating.md with the libvirt-RPC log query
A place for helpers against a running operator, as opposed to the design
docs. First entry is the jq filter for timestamp + rpc: both libvirt
providers log the same "libvirt call" msg, so one select covers kvm and
kvm2, and a fromjson? variant survives a stream with non-JSON lines in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 07:53:12 +00:00
kacerrandClaude Opus 5 c3a581af95 samples: a kvm2 crawl pool, not just the single-proxy sample
proxy_kvm2.yaml shows one VM; nothing showed the shape a crawler actually
leases from. Add a five-Proxy pool on the kvm2-home entry: four share one
labelled cloud-init Secret, and the fifth carries its own inline block
because it runs squid on 8080 and spec.port has to match the http_port a
shared Secret would pin.

It stays out of the samples kustomization so `apply -k config/samples`
doesn't boot a pool along with the one-per-provider samples. All six
objects validate against the installed CRD under --dry-run=server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 07:45:16 +00:00
kacerr 5928c3fe75 Merge pull request 'kvm2 provider: clean-room libvirt/KVM provider, code review, and the four high findings' (#8) from feat/kvm2-provider into main
Reviewed-on: #8
2026-09-17 23:12:46 +02:00
70 changed files with 2026 additions and 423 deletions
+174 -102
View File
@@ -1,113 +1,185 @@
{
"permissions": {
"allow": [
"Bash(go version *)",
"Bash(go mod *)",
"Read(//Users/jan.novak/.claude/skills/go-project-bootstrap/references/**)",
"Bash(ls -la /Users/jan.novak/.claude/skills/go-project-bootstrap/assets/ /Users/jan.novak/.claude/skills/go-project-bootstrap/assets/commands/ /Users/jan.novak/.claude/skills/go-project-bootstrap/assets/agents/ 2>&1)",
"Read(//Users/jan.novak/.claude/skills/go-project-bootstrap/assets/**)",
"Read(//Users/jan.novak/.claude/skills/go-project-bootstrap/assets/agents/**)",
"Read(//Users/jan.novak/.claude/skills/go-project-bootstrap/assets/commands/**)",
"Bash(mkdir -p /Users/jan.novak/srv/go/egress-proxies-operator/docs/plans && touch /Users/jan.novak/srv/go/egress-proxies-operator/docs/plans/.gitkeep && mkdir -p /Users/jan.novak/srv/go/egress-proxies-operator/.claude/commands /Users/jan.novak/srv/go/egress-proxies-operator/.claude/agents && ls -la /Users/jan.novak/srv/go/egress-proxies-operator)",
"Bash(git status)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git branch:*)",
"Bash(git add:*)",
"Bash(git checkout:*)",
"Bash(git pull:*)",
"Bash(git fetch:*)",
"Bash(date:*)",
"Read(//tmp/**)",
"Read(//private/tmp/**)",
"Bash(go build:*)",
"Bash(go test:*)",
"Bash(go vet:*)",
"Bash(go mod:*)",
"Bash(go run:*)",
"Bash(go version:*)",
"Bash(go get:*)",
"Bash(golangci-lint run:*)",
"Bash(golangci-lint --version)",
"Bash(gofumpt:*)",
"Bash(tea pr create:*)",
"Bash(curl -sL https://go.dev/dl/?mode=json)",
"Bash(find / -iname \"docs-conventions.md\" -maxdepth 6)",
"Read(//Users/jan.novak/.claude/**)",
"Read(//Users/jan.novak/.claude/skills/**)",
"Bash(mkdir -p docs/plans && date \"+%Y-%m-%d-%H%M\")",
"Bash(go install *)",
"Bash(go env *)",
"Bash(git rm *)",
"Bash(mkdir -p /Users/jan.novak/srv/go/egress-proxies-operator/docs/plans-executions)",
"Bash(cd /Users/jan.novak/srv/go/egress-proxies-operator *)",
"Bash(echo \"build: $?\")",
"Bash(echo \"vet: $?\")",
"Bash(perl -i -pe 's{^\\\\t\\\\t\\\\t\\\\t\\\\t// TODO\\\\\\(user\\\\\\): Specify other spec details if needed\\\\.\\\\n}{\\\\t\\\\t\\\\t\\\\t\\\\t// A minimal, schema-valid spec so this placeholder test survives the\\\\n\\\\t\\\\t\\\\t\\\\t\\\\t// CEL/CRD validation added in Step 1. Rewritten wholesale in Step 4\\\\n\\\\t\\\\t\\\\t\\\\t\\\\t// alongside the real reconciler and envtest suite.\\\\n\\\\t\\\\t\\\\t\\\\t\\\\tSpec: crawlv1alpha1.ProxySpec{\\\\n\\\\t\\\\t\\\\t\\\\t\\\\t\\\\tMode: crawlv1alpha1.ModeExternal,\\\\n\\\\t\\\\t\\\\t\\\\t\\\\t\\\\tEndpoint: &crawlv1alpha1.EndpointSpec{Host: \"10.0.0.1\"},\\\\n\\\\t\\\\t\\\\t\\\\t\\\\t},\\\\n}' internal/controller/proxy_controller_test.go)",
"Bash(go tool *)",
"Bash(docker version *)",
"Bash(curl -sI --max-time 5 https://hub.docker.com)",
"Bash(kind get *)",
"Bash(curl -s --max-time 5 \"https://hub.docker.com/v2/repositories/ubuntu/squid/tags?page_size=10\")",
"Bash(python3 -c ' *)",
"Bash(curl -s --max-time 5 \"https://hub.docker.com/v2/repositories/ubuntu/squid/tags?page_size=100\")",
"Bash(go doc *)",
"Bash(go list *)",
"Bash(gofmt -w internal/provider/config.go)",
"Bash(gofmt -l .)",
"Bash(git restore *)",
"Bash(make manifests *)",
"Bash(make test *)",
"Bash(KUBEBUILDER_ASSETS=\"/Users/jan.novak/srv/go/egress-proxies-operator/bin/k8s/1.36.2-darwin-arm64\" go test -race ./internal/controller/)",
"Bash(grep -n 'func Channel' -A8 __CMDSUB_OUTPUT__/sigs.k8s.io/controller-runtime@v0.24.1/pkg/source/source.go)",
"Bash(grep -n 'type GenericEvent' __CMDSUB_OUTPUT__/sigs.k8s.io/controller-runtime@v0.24.1/pkg/event/event.go)",
"Bash(KUBEBUILDER_ASSETS=__TRACKED_VAR__/bin/k8s/1.36.2-darwin-arm64 go test -race ./...)",
"Bash(cat >> *)",
"Bash(make run-dev *)",
"Bash(kubectl get *)",
"Bash(kubectl delete *)",
"Bash(make docker-build *)",
"Bash(kind load *)",
"Bash(make deploy *)",
"Bash(kubectl -n egress-proxies-operator-system rollout status deploy/egress-proxies-operator-controller-manager --timeout=120s)",
"Bash(kubectl -n egress-proxies-operator-system rollout restart deploy/egress-proxies-operator-controller-manager)",
"Bash(git -C /Users/jan.novak/srv/go/egress-proxies-operator add docs/plans/2026-08-11-1742-gcp-provider-verbose-logging.md)",
"Bash(git -C /Users/jan.novak/srv/go/egress-proxies-operator commit -m 'Add plan: verbose V-level logging in the GCP provider *)",
"Bash(echo \"exit: $?\")",
"Bash(echo \"tests exit: $?\")",
"Bash(./bin/manager --version)",
"Bash(./bin/manager-stamped --version)",
"Bash(./bin/manager-pkg --version)",
"Bash(docker run *)",
"Bash(kubectl -n egress-proxies-operator-system get pods -o wide)",
"Bash(kubectl -n egress-proxies-operator-system get deploy egress-proxies-operator-controller-manager -o jsonpath='{.spec.template.spec.containers[0].args}')",
"Bash(kubectl -n egress-proxies-operator-system logs deploy/egress-proxies-operator-controller-manager)",
"Bash(python3 -c \"import json; d=json.load\\(open\\('docs/deploy/sa_key.json'\\)\\); print\\(d.get\\('type'\\), d.get\\('client_email'\\)\\)\")",
"Bash(tea pr *)",
"Bash(git worktree *)",
"Bash(python3 -c \"import yaml; yaml.safe_load\\(open\\('.gitea/workflows/build.yaml'\\)\\); print\\('YAML OK'\\)\")",
"Bash(ruby -ryaml -e \"YAML.load_file\\('.gitea/workflows/build.yaml'\\); puts 'YAML OK'\")",
"Bash(git -C /Users/jan.novak/srv/go/egress-proxies-operator status --short --branch)",
"Bash(git -C /Users/jan.novak/srv/go/egress-proxies-operator log --oneline -1)",
"Bash(git -C /Users/jan.novak/srv/go/egress-proxies-operator tag 0.01)",
"Bash(git -C /Users/jan.novak/srv/go/egress-proxies-operator push origin 0.01)",
"Bash(chmod +x docs/demo/run-demo.sh)",
"Bash(bash -n docs/demo/run-demo.sh)",
"Bash(git add *)",
"Bash(git commit -q -m 'Add plan: KVM/libvirt provider *)",
"Bash(go get *)",
"Bash(go build *)",
"Bash(go test *)",
"Bash(git commit *)"
"Bash(go vet *)",
"Bash(go run *)",
"Bash(go mod *)",
"Bash(go get *)",
"Bash(go install *)",
"Bash(go env *)",
"Bash(go tool *)",
"Bash(go doc *)",
"Bash(go list *)",
"Bash(go version *)",
"Bash(gofmt *)",
"Bash(gofumpt *)",
"Bash(golangci-lint run *)",
"Bash(golangci-lint --version)",
"Bash(./bin/manager* --version)",
"Bash(make -n *)",
"Bash(make manifests *)",
"Bash(make test *)",
"Bash(make test-integration-kvm *)",
"Bash(make run-dev *)",
"Bash(make docker-build *)",
"Bash(make deploy *)",
"Bash(make kvm-ssh-secret *)",
"Bash(git status *)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git branch)",
"Bash(git branch -a)",
"Bash(git branch --show-current)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git fetch *)",
"Bash(git pull *)",
"Bash(git worktree list *)",
"Bash(git config --get *)",
"Bash(git config --list *)",
"Bash(git merge-tree *)",
"Bash(git ls-remote *)",
"Bash(git -c credential.helper='!f\\(\\) { echo username=kacerr; echo \"password=$\\(tr -d \"[:space:]\" < ~/.config/gitea-token\\)\"; }; f' push origin feat/kvm-provider)",
"Bash(git -c credential.helper='!f\\(\\) { echo username=kacerr; echo \"password=$\\(tr -d \"[:space:]\" < ~/.config/gitea-token\\)\"; }; f' push -q origin feat/kvm-provider)",
"Bash(git -c credential.helper='!f\\(\\) { echo username=kacerr; echo \"password=$\\(tr -d \"[:space:]\" < ~/.config/gitea-token\\)\"; }; f' push -u origin feat/kvm-provider)",
"Bash(tea pr create *)",
"Bash(tea pr list *)",
"Bash(curl -sL https://go.dev/dl/?mode=json)",
"Bash(curl -s --max-time 10 https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/*)",
"Bash(curl -s --max-time 10 'https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/*')",
"Bash(curl -sI --max-time 10 https://gitea.home.hrajfrisbee.cz/v2/kacerr/egress-proxies-operator/*)",
"Bash(curl -s -H \"Authorization: token $\\(tr -d '[:space:]' < ~/.config/gitea-token\\)\" https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/pulls/5)",
"Bash(kubectl get *)",
"Bash(kubectl wait *)",
"Bash(kubectl kustomize *)",
"Bash(kubectl apply *)",
"Bash(kubectl run *)",
"Bash(kubectl config current-context)",
"Bash(kubectl config get-contexts *)",
"Bash(kubectl config view *)",
"Bash(kubectl -n egress-proxies-operator-system get *)",
"Bash(kubectl -n egress-proxies-operator-system logs *)",
"Bash(kubectl -n egress-proxies-operator-system rollout status *)",
"Bash(kubectl -n egress-proxies-operator-system rollout restart *)",
"Bash(kind get *)",
"Bash(kind load *)",
"Bash(kind version *)",
"Bash(docker version *)",
"Bash(docker info *)",
"Bash(docker image ls *)",
"Bash(docker image inspect *)",
"Bash(docker manifest inspect *)",
"Bash(docker history *)",
"Bash(docker save *)",
"Bash(docker run *)",
"Bash(docker exec *)",
"Bash(virsh -c qemu:///system list *)",
"Bash(virsh -c qemu:///system net-list *)",
"Bash(virsh -c qemu:///system net-dumpxml *)",
"Bash(virsh -c qemu:///system pool-list *)",
"Bash(virsh -c qemu:///system pool-dumpxml *)",
"Bash(virsh -c qemu:///system vol-list *)",
"Bash(ssh -i ~/.ssh/proxy-operator_ed25519 * novakj@beelink.tail8c69c.ts.net *)",
"Bash(ip -br *)",
"Bash(getent hosts *)",
"Bash(tailscale status *)",
"Bash(python3 *)",
"Bash(grep *)",
"Bash(echo \"*$?\")",
"Bash(date *)",
"Bash(env)",
"Bash(command -v *)",
"Bash(cat >> *)",
"Bash(tar -tf *)",
"Bash(bash -n *)",
"Bash(mkdir -p docs/*)",
"Read(//home/novakj/**)",
"Read(//Users/jan.novak/.claude/**)",
"Read(//tmp/**)",
"Read(//private/tmp/**)",
"Read(//etc/**)",
"Read(//dev/**)",
"Read(//run/libvirt/**)",
"WebSearch",
"WebFetch(domain:gitlab.com)",
"WebFetch(domain:bugs.debian.org)",
"WebFetch(domain:tracker.debian.org)",
"WebFetch(domain:cloud.debian.org)",
"WebFetch(domain:alioth-lists.debian.net)",
"WebFetch(domain:www.mail-archive.com)",
"WebFetch(domain:forum.proxmox.com)",
"Bash(curl -s --max-time 10 'https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/pulls?state=open')",
"Bash(python3 -c 'import json,sys; [print\\(p[\"number\"], p[\"head\"][\"ref\"], \"->\", p[\"base\"][\"ref\"], \"|\", p[\"title\"]\\) for p in json.load\\(sys.stdin\\)]')",
"Bash(curl -s --max-time 10 'https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/pulls/5')",
"Bash(python3 -c 'import json,sys; p=json.load\\(sys.stdin\\); print\\(p[\"state\"], \"merged=\", p[\"merged\"], p.get\\(\"merge_commit_sha\"\\)\\)')",
"Read(//home/novakj/srv/egress-proxies-operator-simplify/origin/**)",
"Bash(echo \"exit=$?\")",
"Read(//home/novakj/srv/egress-proxies-operator-simplify/**)",
"Bash(git -C /home/novakj/srv/egress-proxies-operator-simplify diff 82eeeef -- internal/controller/proxy_controller.go internal/provider/kvm/errors.go)",
"Bash(git switch *)",
"Bash(git -C /home/novakj/srv/egress-proxies-operator-simplify add -A)",
"Bash(git -C /home/novakj/srv/egress-proxies-operator-simplify commit -q -m 'WIP: uncommitted simplify-pass edits preserved before worktree removal *)",
"Bash(git -C /home/novakj/srv/egress-proxies-operator-simplify log --oneline -1)",
"Bash(git worktree *)",
"Bash(git branch *)",
"Bash(git stash *)",
"Bash(make generate *)",
"Bash(bin/kustomize build *)",
"Bash(grep -Ev \"^\\\\s*$\")",
"Bash(make lint *)",
"Bash(command -v kind docker)",
"Bash(bin/golangci-lint version *)",
"Bash(make test-e2e *)",
"Bash(echo \"make test-e2e exit=$?\")",
"Bash(ls \"$\\(go env GOMODCACHE\\)/golang.org/\")",
"Bash(ls \"$\\(go env GOMODCACHE\\)/golang.org/toolchain@\"* -d)",
"Bash(ps -eo pid,ppid,etime,args)",
"Bash(kubectl config *)",
"Bash(git commit -q -m 'Plan the provider blueprint document *)",
"Bash(grep -nE '^#{1,3} ' docs/architecture.md; echo ---; sed -n '/Implemented providers/,/^## /p' README.md | head -30; echo ---; sed -n 140,180p config/manager/manager.yaml; echo ---; cat Dockerfile; echo ---; ls -a | grep -i golangci; echo ---; grep -nE 'Placement|placement|Region|Zone|MachineType|Image ' api/v1alpha1/proxy_types.go | head -40; echo ---; grep -nE 'Type|type' api/v1alpha1/providerhealth_types.go | head -20; echo ---; grep -n -i 'placement' docs/api.md | head; echo ---; grep -nE 'WithTimeout|Deadline|timeout' internal/controller/proxy_controller.go internal/gc/gc.go internal/providerhealth/monitor.go | head -20; echo ---; cat .gitea/workflows/build.yaml | sed -n 1,60p)",
"WebFetch(domain:pve.proxmox.com)",
"Bash(git -c credential.helper='!f\\(\\) { echo username=kacerr; echo \"password=$\\(tr -d \"[:space:]\" < ~/.config/gitea-token\\)\"; }; f' push -u origin feat/provider-blueprint)",
"Bash(curl -s -H \"Authorization: token $\\(tr -d '[:space:]' < ~/.config/gitea-token\\)\" \"https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/pulls?state=open\")",
"Bash(curl -s -H \"Authorization: token $\\(tr -d '[:space:]' < ~/.config/gitea-token\\)\" \"https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/pulls/7\")",
"Bash(curl -s -X PATCH -H \"Authorization: token $\\(tr -d '[:space:]' < ~/.config/gitea-token\\)\" -H 'Content-Type: application/json' -d @/tmp/claude-1000/-home-novakj-srv-egress-proxies-operator/7662ab9d-4540-4676-a943-0874ac6b081d/scratchpad/pr7.json \"https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/pulls/7\")",
"Bash(bin/golangci-lint run *)",
"Bash(awk '/^## The image cache/,/^## [^T]/' docs/kvm-provider.md)",
"Bash(awk '/^#+ .*Step 11/,0' docs/plans/2026-09-16-1333-kvm2-provider.md)",
"Bash(sort -t: -k2 -u)",
"Bash(awk '/kvm2/&&/^#/{f=1} f' docs/testing.md)",
"Bash(awk -F'\"' '{print $2}')",
"Bash(sed -n '/targets:/,/type: object/p' config/crd/bases/*providerhealths.yaml | sed -n 1,45p)",
"Bash(ssh -i ~/.ssh/proxy-operator_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10 -o UserKnownHostsFile=~/.ssh/kvm_known_hosts -o StrictHostKeyChecking=yes novakj@beelink 'hostname; id -un; virsh -c qemu:///system version; virsh -c qemu:///system pool-list --all; virsh -c qemu:///system list --all')",
"Bash(awk '{print $1, $2}' ~/.ssh/kvm_known_hosts)",
"Bash(ssh-keygen -F beelink -f ~/.ssh/kvm_known_hosts)",
"Bash(ssh -i ~/.ssh/proxy-operator_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10 -o UserKnownHostsFile=~/.ssh/kvm_known_hosts -o StrictHostKeyChecking=yes novakj@beelink.tail8c69c.ts.net 'hostname; id -un; id -Gn; virsh -c qemu:///system version; virsh -c qemu:///system pool-list --all --details; virsh -c qemu:///system list --all; command -v qemu-img')",
"Bash(qemu-img --version)",
"Bash(./probe)",
"Bash(./probe -proxyrcv 8192)",
"Bash(./probe -small)",
"Bash(sysctl net.ipv4.tcp_wmem net.ipv4.tcp_retries2)",
"Bash(GOFLAGS=-mod=mod GOPROXY=off go build -o probe .)",
"Bash(./probe -clientsnd 131072 -proxyrcv 8192)",
"Bash(truncate -s 64M gpt.raw)",
"Bash(sfdisk -l gptshort.raw)",
"Bash(sgdisk -v gptshort.raw)",
"Bash(mkdir -p /tmp/claude-1000/-home-novakj-srv-egress-proxies-operator/8db56daf-938c-48b8-8a17-084f3e1dea44/scratchpad)",
"Bash(curl -sS -m 30 -o storage_driver.c https://raw.githubusercontent.com/libvirt/libvirt/v11.6.0/src/storage/storage_driver.c)",
"Bash(GOFLAGS=-mod=mod GOPROXY=off go vet ./...)",
"Bash(GOFLAGS=-mod=mod GOPROXY=off go test -count=1 -run TestLeak -v . -args -n 1000 -transport unix)",
"Bash(curl -sS -m 30 -o storage_util.c https://raw.githubusercontent.com/libvirt/libvirt/v11.6.0/src/storage/storage_util.c)",
"Bash(awk '/^virStorageBackendVolUploadLocal/,/^}/' storage_util.c)",
"Bash(export GOFLAGS=-mod=mod GOPROXY=off)",
"Bash(awk '/WARNING: DATA RACE/{c++} c==1' race_ssh.log)",
"Bash(kubectl version *)",
"Bash(timeout 5 kubectl cluster-info)"
],
"additionalDirectories": [
"/Users/jan.novak/srv/go/egress-proxies-operator/.claude",
"/Users/jan.novak/srv/go/egress-proxies-operator/docs/plans",
"/Users/jan.novak/srv/go/egress-proxies-operator/docs",
"/Users/jan.novak/srv/go/egress-proxies-operator/docs/prompts",
"/tmp",
"/Users/jan.novak/srv/go/egress-proxies-operator/docs/plans-executions"
"/home/novakj/srv/egress-proxies-operator",
"/home/novakj/srv/egress-proxies-operator/config/crd/bases"
]
}
}
+42 -320
View File
@@ -1,320 +1,42 @@
# egress-proxies-operator - AI Agent Guide
## Project Structure
**Single-group layout (default):**
```
cmd/main.go Manager entry (registers controllers/webhooks)
api/<version>/*_types.go CRD schemas (+kubebuilder markers)
api/<version>/zz_generated.* Auto-generated (DO NOT EDIT)
internal/controller/* Reconciliation logic
internal/webhook/* Validation/defaulting (if present)
config/crd/bases/* Generated CRDs (DO NOT EDIT)
config/rbac/role.yaml Generated RBAC (DO NOT EDIT)
config/samples/* Example CRs (edit these)
Makefile Build/test/deploy commands
PROJECT Kubebuilder metadata Auto-generated (DO NOT EDIT)
```
**Multi-group layout** (for projects with multiple API groups):
```
api/<group>/<version>/*_types.go CRD schemas by group
internal/controller/<group>/* Controllers by group
internal/webhook/<group>/<version>/* Webhooks by group and version (if present)
```
Multi-group layout organizes APIs by group name (e.g., `batch`, `apps`). Check the `PROJECT` file for `multigroup: true`.
**To convert to multi-group layout:**
1. Run: `kubebuilder edit --multigroup=true`
2. Move APIs: `mkdir -p api/<group> && mv api/<version> api/<group>/`
3. Move controllers: `mkdir -p internal/controller/<group> && mv internal/controller/*.go internal/controller/<group>/`
4. Move webhooks (if present): `mkdir -p internal/webhook/<group> && mv internal/webhook/<version> internal/webhook/<group>/`
5. Update import paths in all files
6. Fix `path` in `PROJECT` file for each resource
7. Update test suite CRD paths (add one more `..` to relative paths)
## Critical Rules
### Never Edit These (Auto-Generated)
- `config/crd/bases/*.yaml` - from `make manifests`
- `config/rbac/role.yaml` - from `make manifests`
- `config/webhook/manifests.yaml` - from `make manifests`
- `**/zz_generated.*.go` - from `make generate`
- `PROJECT` - from `kubebuilder [OPTIONS]`
### Never Remove Scaffold Markers
Do NOT delete `// +kubebuilder:scaffold:*` comments. CLI injects code at these markers.
### Keep Project Structure
Do not move files around. The CLI expects files in specific locations.
### Always Use CLI Commands
Always use `kubebuilder create api` and `kubebuilder create webhook` to scaffold. Do NOT create files manually.
### E2E Tests Require an Isolated Kind Cluster
The e2e tests are designed to validate the solution in an isolated environment (similar to GitHub Actions CI).
Ensure you run them against a dedicated [Kind](https://kind.sigs.k8s.io/) cluster (not your “real” dev/prod cluster).
## After Making Changes
**After editing `*_types.go` or markers:**
```
make manifests # Regenerate CRDs/RBAC from markers
make generate # Regenerate DeepCopy methods
```
**After editing `*.go` files:**
```
make lint-fix # Auto-fix code style
make test # Run unit tests
```
## CLI Commands Cheat Sheet
### Create API (your own types)
```bash
kubebuilder create api --group <group> --version <version> --kind <Kind>
```
### Deploy Image Plugin (scaffold to deploy/manage ANY container image)
Generate a controller that deploys and manages a container image (nginx, redis, memcached, your app, etc.):
```bash
# Example: deploying memcached
kubebuilder create api --group example.com --version v1alpha1 --kind Memcached \
--image=memcached:alpine \
--plugins=deploy-image.go.kubebuilder.io/v1-alpha
```
Scaffolds good-practice code: reconciliation logic, status conditions, finalizers, RBAC. Use as a reference implementation.
### Create Webhooks
```bash
# Validation + defaulting
kubebuilder create webhook --group <group> --version <version> --kind <Kind> \
--defaulting --programmatic-validation
# Conversion webhook (for multi-version APIs)
kubebuilder create webhook --group <group> --version v1 --kind <Kind> \
--conversion --spoke v2
```
### Controller for Core Kubernetes Types
```bash
# Watch Pods
kubebuilder create api --group core --version v1 --kind Pod \
--controller=true --resource=false
# Watch Deployments
kubebuilder create api --group apps --version v1 --kind Deployment \
--controller=true --resource=false
```
### Controller for External Types (e.g., from other operators)
Watch resources from external APIs (cert-manager, Argo CD, Istio, etc.):
```bash
# Example: watching cert-manager Certificate resources
kubebuilder create api \
--group cert-manager --version v1 --kind Certificate \
--controller=true --resource=false \
--external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 \
--external-api-domain=io \
--external-api-module=github.com/cert-manager/cert-manager
```
**Note:** Use `--external-api-module=<module>@<version>` only if you need a specific version. Otherwise, omit `@<version>` to use what's in go.mod.
### Webhook for External Types
```bash
# Example: validating external resources
kubebuilder create webhook \
--group cert-manager --version v1 --kind Issuer \
--defaulting \
--external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 \
--external-api-domain=io \
--external-api-module=github.com/cert-manager/cert-manager
```
## Testing & Development
```bash
make test # Run unit tests (uses envtest: real K8s API + etcd)
make run # Run locally (uses current kubeconfig context)
```
Tests use **Ginkgo + Gomega** (BDD style). Check `suite_test.go` for setup.
## Deployment Workflow
```bash
# 1. Regenerate manifests
make manifests generate
# 2. Build & deploy
export IMG=<registry>/<project>:tag
make docker-build docker-push IMG=$IMG # Or: kind load docker-image $IMG --name <cluster>
make deploy IMG=$IMG
# 3. Test
kubectl apply -k config/samples/
# 4. Debug
kubectl logs -n <project>-system deployment/<project>-controller-manager -c manager -f
```
### API Design
**Key markers for** `api/<version>/*_types.go`:
```go
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Namespaced
// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=".status.conditions[?(@.type=='Ready')].status"
// On fields:
// +kubebuilder:validation:Required
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:MaxLength=100
// +kubebuilder:validation:Pattern="^[a-z]+$"
// +kubebuilder:default="value"
```
- **Use** `metav1.Condition` for status (not custom string fields)
- **Use predefined types**: `metav1.Time` instead of `string` for dates
- **Follow K8s API conventions**: Standard field names (`spec`, `status`, `metadata`)
### Controller Design
**RBAC markers in** `internal/controller/*_controller.go`:
```go
// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds/finalizers,verbs=update
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
```
**Implementation rules:**
- **Idempotent reconciliation**: Safe to run multiple times
- **Re-fetch before updates**: `r.Get(ctx, req.NamespacedName, obj)` before `r.Update` to avoid conflicts
- **Structured logging**: `log := log.FromContext(ctx); log.Info("msg", "key", val)`
- **Owner references**: Enable automatic garbage collection (`SetControllerReference`)
- **Watch secondary resources**: Use `.Owns()` or `.Watches()`, not just `RequeueAfter`
- **Finalizers**: Clean up external resources (buckets, VMs, DNS entries)
### Logging
**Follow Kubernetes logging message style guidelines:**
- Start from a capital letter
- Do not end the message with a period
- Active voice: subject present (`"Deployment could not create Pod"`) or omitted (`"Could not create Pod"`)
- Past tense: `"Could not delete Pod"` not `"Cannot delete Pod"`
- Specify object type: `"Deleted Pod"` not `"Deleted"`
- Balanced key-value pairs
```go
log.Info("Starting reconciliation")
log.Info("Created Deployment", "name", deploy.Name)
log.Error(err, "Failed to create Pod", "name", name)
```
**Reference:** https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md#message-style-guidelines
### Webhooks
- **Create all types together**: `--defaulting --programmatic-validation --conversion`
- **When`--force`is used**: Backup custom logic first, then restore after scaffolding
- **For multi-version APIs**: Use hub-and-spoke pattern (`--conversion --spoke v2`)
- Hub version: Usually oldest stable version (v1)
- Spoke versions: Newer versions that convert to/from hub (v2, v3)
- Example: `--group crew --version v1 --kind Captain --conversion --spoke v2` (v1 is hub, v2 is spoke)
### Learning from Examples
The **deploy-image plugin** scaffolds a complete controller following good practices. Use it as a reference implementation:
```bash
kubebuilder create api --group example --version v1alpha1 --kind MyApp \
--image=<your-image> --plugins=deploy-image.go.kubebuilder.io/v1-alpha
```
Generated code includes: status conditions (`metav1.Condition`), finalizers, owner references, events, idempotent reconciliation.
## Distribution Options
### Option 1: YAML Bundle (Kustomize)
```bash
# Generate dist/install.yaml from Kustomize manifests
make build-installer IMG=<registry>/<project>:tag
```
**Key points:**
- The `dist/install.yaml` is generated from Kustomize manifests (CRDs, RBAC, Deployment)
- Commit this file to your repository for easy distribution
- Users only need `kubectl` to install (no additional tools required)
**Example:** Users install with a single command:
```bash
kubectl apply -f https://raw.githubusercontent.com/<org>/<repo>/<tag>/dist/install.yaml
```
### Option 2: Helm Chart
```bash
kubebuilder edit --plugins=helm/v2-alpha # Generates dist/chart/ (default)
kubebuilder edit --plugins=helm/v2-alpha --output-dir=charts # Generates charts/chart/
```
**For development:**
```bash
make helm-deploy IMG=<registry>/<project>:<tag> # Deploy manager via Helm
make helm-deploy IMG=$IMG HELM_EXTRA_ARGS="--set ..." # Deploy with custom values
make helm-status # Show release status
make helm-uninstall # Remove release
make helm-history # View release history
make helm-rollback # Rollback to previous version
```
**For end users/production:**
```bash
helm install my-release ./<output-dir>/chart/ --namespace <ns> --create-namespace
```
**Important:** If you add webhooks or modify manifests after initial chart generation:
1. Backup any customizations in `<output-dir>/chart/values.yaml` and `<output-dir>/chart/manager/manager.yaml`
2. Re-run: `kubebuilder edit --plugins=helm/v2-alpha --force` (use same `--output-dir` if customized)
3. Manually restore your custom values from the backup
### Publish Container Image
```bash
export IMG=<registry>/<project>:<version>
make docker-build docker-push IMG=$IMG
```
## References
### Essential Reading
- **Kubebuilder Book**: https://book.kubebuilder.io (comprehensive guide)
- **controller-runtime FAQ**: https://github.com/kubernetes-sigs/controller-runtime/blob/main/FAQ.md (common patterns and questions)
- **Good Practices**: https://book.kubebuilder.io/reference/good-practices.html (why reconciliation is idempotent, status conditions, etc.)
- **Logging Conventions**: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md#message-style-guidelines (message style, verbosity levels)
### API Design & Implementation
- **API Conventions**: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md
- **Operator Pattern**: https://kubernetes.io/docs/concepts/extend-kubernetes/operator/
- **Markers Reference**: https://book.kubebuilder.io/reference/markers.html
### Tools & Libraries
- **controller-runtime**: https://github.com/kubernetes-sigs/controller-runtime
- **controller-tools**: https://github.com/kubernetes-sigs/controller-tools
- **Kubebuilder Repo**: https://github.com/kubernetes-sigs/kubebuilder
# egress-proxies-operator
## Sources and architecture
- Trust `go.mod`, `Makefile`, and current code over version tables or bootstrap prose. `CLAUDE.md`'s opening architecture/commands sections are stale; this is a working operator, not an empty scaffold.
- `cmd/main.go` assembles the provider registry, reconciler, proxy health engine, in-memory lease store, discovery HTTP API, orphan GC, and provider-health monitor on one controller-runtime manager.
- `api/v1alpha1` contains namespaced `Proxy` and cluster-scoped `ProviderHealth` resources (`crawl.example.com`). Validation/defaulting uses CRD markers/CEL; there are no admission webhooks.
- `internal/provider` defines the backend contract independently of CRD types. Constructors are registered in `cmd/main.go`; `spec.provider` selects a configured **instance name**, not a provider type. `kvm` and `kvm2` are separate implementations and do not adopt each other's VMs.
- Before changing a provider, read `docs/provider-contract.md`; use `docs/provider-spec-template.md` for a new one. Callers rely on self-contained provider IDs, truthful `ErrNotFound`, idempotent create/delete, and persisted UID/creation time for orphan GC. See `docs/providers-existing.md` for implementation-specific exceptions.
- `docs/architecture.md` explains component ownership and state transitions; `docs/api.md` documents discovery/leases; `docs/testing.md` covers test boundaries and real-infrastructure setup.
## Commands and verification
- Go version comes from `go.mod` (currently 1.26.0). Make installs development tools into `bin/`.
- After Go changes: `make lint-fix`, then `make test`. The latter regenerates manifests/DeepCopy, formats, vets, sets up envtest, then runs non-e2e tests with `-race` and writes `cover.out`.
- Fast checks: `go test -short -race ./...` skips envtest. Focused package/test: `go test -race ./internal/lease/` or `go test -race -run TestAcquire ./internal/lease/`.
- Envtest needs local API-server/etcd binaries; use `make setup-envtest` before targeted runs. For the controller suite with the current Kubernetes minor:
```sh
KUBEBUILDER_ASSETS="$(./bin/setup-envtest use 1.36 --bin-dir "$PWD/bin" -p path)" go test -race ./internal/controller/ -run TestControllers -ginkgo.focus='<spec regex>'
```
The Makefile derives the envtest **minor** from `go.mod`; do not replace it with a dependency patch version that may have no published binary bundle.
- Tests are mostly stdlib `testing`; controller envtest and e2e use Ginkgo/Gomega. Fake clients do not execute CEL or schema defaults: test those with envtest, and use the API's `*OrDefault` helpers in unit tests. Envtest has no kubelet, so it cannot prove Squid starts or serves traffic.
- `make lint` / `make lint-fix` builds a custom golangci-lint with the `logcheck` plugin from `.custom-gcl.yml`; a stock global binary is insufficient. `.golangci.yml` forbids `sort` (use `slices`). Logging uses controller-runtime/logr, with Kubernetes-style messages.
- Real-host tests are opt-in: `make test-integration-kvm` / `make test-integration-kvm2` require the corresponding `KVM_TEST_*` / `KVM2_TEST_*` variables from `docs/testing.md`. After provider refactors, compile-check these with `go vet -tags=integration ./...`.
- E2E requires Docker, kind, kubectl, and an isolated cluster. `make test-e2e` uses `KIND_CLUSTER=egress-proxies-operator-test-e2e`; ensure kubectl targets that cluster, especially if it already exists. On failure, run `make cleanup-test-e2e` explicitly. Compile-check with `go vet -tags=e2e ./...`. Tracing specs additionally require both `TEMPO_URL` and `OTLP_ENDPOINT`.
- `.gitea/workflows/build.yaml` runs only on tags/manual dispatch; its gate is `go vet ./...`, `go build ./...`, `go test -short ./...`. It does not replace local lint, race, or envtest verification.
## Code generation
- After API types or Kubebuilder markers change, run `make manifests generate`. Edit source markers, never generated `config/crd/bases/*.yaml`, `config/rbac/role.yaml`, or `**/zz_generated.*.go`.
- Keep `+kubebuilder:rbac` markers in standalone package-level comment blocks, outside function/type documentation; otherwise controller-gen can silently ignore them. Inspect `git diff config/rbac/role.yaml` after generation.
- Preserve `+kubebuilder:scaffold:*` markers and the single-group layout. Use `kubebuilder create api` / `kubebuilder create webhook` for new scaffolding; `PROJECT` is CLI-managed.
## Behaviour and operational gotchas
- Managed proxies are replace-on-change: `internal/controller/spechash.go` hashes placement, resolved cloud-init content, and effective port. Do not hash all of `ProxySpec`: attributes, lease limits, and health settings must not churn VMs.
- Cloud-init Secrets must carry `crawl.example.com/cloud-init: "true"`; the manager's Secret cache excludes unlabelled Secrets. Rotating labelled Secret content triggers VM replacement.
- Proxy health checks fetch **through** the proxy. The health engine supplies snapshots/events and the reconciler writes status; status-affecting transitions drive writes, so `lastHealthCheckTime` is not a last-probe heartbeat.
- Leases/cooldowns are in-memory and disappear on restart. Discovery is not leader-elected; the deployment uses one replica. Adding replicas alone does not provide shared lease capacity.
- Use `make run-dev` locally: plain `make run` omits the required `--providers-config`. Host-side probes generally cannot route to kind pod IPs; deploy in-cluster using the README quickstart to verify kubernetes-provider health.
- `make deploy IMG=...` and `make build-installer IMG=...` modify `config/manager/kustomization.yaml` in place. Check that diff before including it in a change.
- Namespace-restricted mode disables orphan GC by default. `--gc-allow-namespaced` bypasses that guard and can classify instances owned outside the visible namespace as orphans.
+21
View File
@@ -1,5 +1,26 @@
# Changelog
## 2026-09-18 07:52 UTC — docs/operating.md, starting with the libvirt-RPC log query
New home for day-to-day helpers against a running operator, seeded with the `jq` query
that pulls timestamp + rpc out of the manager's JSON logs (both libvirt providers log the
same `"libvirt call"` msg, so one filter covers `kvm` and `kvm2`), plus the `fromjson?`
variant for streams carrying non-JSON lines. Linked from the README's doc index.
## 2026-09-18 07:44 UTC — a kvm2 crawl-pool sample
Until now the only kvm2 manifest was the one-VM `config/samples/proxy_kvm2.yaml`, which
left the multi-proxy shape — the one a crawler actually leases from — undocumented.
- Added `config/samples/proxy_kvm2_crawl_pool.yaml`: five Managed Proxies on the `kvm2-home`
entry, four sharing one labelled cloud-init Secret and one inline (it runs squid on 8080,
so it cannot share a Secret that pins `http_port 3128`). Covers the `tier` attribute the
discovery API selects on, a `medium`/`ubuntu-24.04` member with a raised `maxLeases`, and
a member with a tightened `healthCheck`.
- Kept out of `config/samples/kustomization.yaml` on purpose so `apply -k` doesn't boot a
whole pool; the exclusion is commented there next to the existing `providers-config.yaml`
one. Validated with `kubectl apply --dry-run=server`.
## 2026-09-17 20:36 UTC — kvm2: the four high findings from the code review, and its first run on a real hypervisor
A scoped review of the kvm2 provider (`review/REPORT.md`: 52 findings, 4 high, 20 medium,
+3 -1
View File
@@ -310,4 +310,6 @@ and the manual kind verification procedure — is in
Project layout, reconcile-loop diagrams, and the decision log are in
[docs/architecture.md](docs/architecture.md); the build history is in
[docs/plans-executions/](docs/plans-executions/).
[docs/plans-executions/](docs/plans-executions/). Day-to-day helpers for a
running operator — log queries and one-off inspections — are collected in
[docs/operating.md](docs/operating.md).
+3
View File
@@ -1,6 +1,9 @@
## Append samples of your project ##
# providers-config.yaml is deliberately absent: it is a sample
# --providers-config file, not a Kubernetes manifest.
# proxy_kvm2_crawl_pool.yaml is deliberately absent too: it is a whole pool
# of VMs, so it stays an explicit `kubectl apply -f` rather than something
# `apply -k` boots along with the one-per-provider samples.
resources:
- proxy_kubernetes.yaml
- proxy_gcp.yaml
@@ -0,0 +1,103 @@
# A pool of Managed crawl proxies on the kvm2 provider — the multi-proxy
# counterpart to the single proxy_kvm2.yaml sample. Everything here boots on
# the one host the kvm2 entry configures (beelink); the proxies differ in the
# attributes crawlers select on, in size, and in image.
#
# Requires the same prerequisites as proxy_kvm2.yaml: a providers-config
# entry named "kvm2-home" and the kvm-ssh Secret that the kvm and kvm2
# providers share (docs/kvm2-provider.md).
#
# Deliberately NOT in kustomization.yaml: `kubectl apply -k config/samples`
# would boot every VM in here at once. Apply it on its own when you want the
# pool:
#
# kubectl apply -f config/samples/proxy_kvm2_crawl_pool.yaml
#
# Crawlers reach these through the discovery API by attribute, e.g.
# `GET /v1/proxies?attr.purpose=crawl&healthy=true`, or by leasing one with
# the same selector. Attribute matching is exact equality, so keep the keys
# consistent across the pool and vary only the values you want to select on.
#
# The first Proxy for an image on the host waits for that image to download;
# the rest reuse the cached copy. debian-13 and ubuntu-24.04 are separate
# downloads, so the ubuntu member below is the slow one on a cold host.
---
# One cloud-init Secret shared by the whole pool: the proxies below point at
# it with secretRef instead of each carrying its own copy of the squid
# config. The crawl.example.com/cloud-init label is mandatory — the manager's
# cache only holds Secrets that carry it, so an unlabelled Secret is
# invisible to the operator and the Proxy never resolves.
#
# Editing this Secret changes the resolved cloud-init of every Proxy that
# references it, and that replaces each of their VMs. That is the trade for
# sharing one definition: convenient for a config rollout, disruptive if you
# only meant to touch one member. Give a member its own Secret (or an inline
# block) when it needs to diverge.
apiVersion: v1
kind: Secret
metadata:
name: crawl-squid-cloud-init
labels:
crawl.example.com/cloud-init: "true"
type: Opaque
stringData:
# The default key name; SecretKeySelector.key defaults to user-data, so
# the proxies below omit it.
user-data: |
#cloud-config
packages:
- squid
write_files:
- path: /etc/squid/conf.d/proxy-operator.conf
content: |
http_port 3128
http_access allow all
via off
forwarded_for off
runcmd:
- systemctl restart squid
# Optional: log in with `ssh debian@<ip>` (or `ubuntu@<ip>` on the
# ubuntu-24.04 member) to look around; `virsh console <name>` on the
# host works too.
# ssh_authorized_keys:
# - ssh-ed25519 AAAA... you@laptop
---
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: crawl-home-1
spec:
mode: Managed
provider: kvm2-home
placement:
zone: beelink
machineType: small
image: debian-13
port: 3128
cloudInit:
secretRef:
name: crawl-squid-cloud-init
attributes:
geo: home
purpose: crawl
tier: general
# ---
# apiVersion: crawl.example.com/v1alpha1
# kind: Proxy
# metadata:
# name: crawl-home-2
# spec:
# mode: Managed
# provider: kvm2-home
# placement:
# zone: beelink
# machineType: small
# image: debian-13
# port: 3128
# cloudInit:
# secretRef:
# name: crawl-squid-cloud-init
# attributes:
# geo: home
# purpose: crawl
# tier: general
+190
View File
@@ -0,0 +1,190 @@
# A pool of Managed crawl proxies on the kvm2 provider — the multi-proxy
# counterpart to the single proxy_kvm2.yaml sample. Everything here boots on
# the one host the kvm2 entry configures (beelink); the proxies differ in the
# attributes crawlers select on, in size, and in image.
#
# Requires the same prerequisites as proxy_kvm2.yaml: a providers-config
# entry named "kvm2-home" and the kvm-ssh Secret that the kvm and kvm2
# providers share (docs/kvm2-provider.md).
#
# Deliberately NOT in kustomization.yaml: `kubectl apply -k config/samples`
# would boot every VM in here at once. Apply it on its own when you want the
# pool:
#
# kubectl apply -f config/samples/proxy_kvm2_crawl_pool.yaml
#
# Crawlers reach these through the discovery API by attribute, e.g.
# `GET /v1/proxies?attr.purpose=crawl&healthy=true`, or by leasing one with
# the same selector. Attribute matching is exact equality, so keep the keys
# consistent across the pool and vary only the values you want to select on.
#
# The first Proxy for an image on the host waits for that image to download;
# the rest reuse the cached copy. debian-13 and ubuntu-24.04 are separate
# downloads, so the ubuntu member below is the slow one on a cold host.
---
# One cloud-init Secret shared by the whole pool: the proxies below point at
# it with secretRef instead of each carrying its own copy of the squid
# config. The crawl.example.com/cloud-init label is mandatory — the manager's
# cache only holds Secrets that carry it, so an unlabelled Secret is
# invisible to the operator and the Proxy never resolves.
#
# Editing this Secret changes the resolved cloud-init of every Proxy that
# references it, and that replaces each of their VMs. That is the trade for
# sharing one definition: convenient for a config rollout, disruptive if you
# only meant to touch one member. Give a member its own Secret (or an inline
# block) when it needs to diverge.
apiVersion: v1
kind: Secret
metadata:
name: crawl-squid-cloud-init
labels:
crawl.example.com/cloud-init: "true"
type: Opaque
stringData:
# The default key name; SecretKeySelector.key defaults to user-data, so
# the proxies below omit it.
user-data: |
#cloud-config
packages:
- squid
write_files:
- path: /etc/squid/conf.d/proxy-operator.conf
content: |
http_port 3128
http_access allow all
via off
forwarded_for off
runcmd:
- systemctl restart squid
# Optional: log in with `ssh debian@<ip>` (or `ubuntu@<ip>` on the
# ubuntu-24.04 member) to look around; `virsh console <name>` on the
# host works too.
# ssh_authorized_keys:
# - ssh-ed25519 AAAA... you@laptop
---
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: crawl-home-1
spec:
mode: Managed
provider: kvm2-home
placement:
zone: beelink
machineType: small
image: debian-13
port: 3128
cloudInit:
secretRef:
name: crawl-squid-cloud-init
attributes:
geo: home
purpose: crawl
tier: general
---
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: crawl-home-2
spec:
mode: Managed
provider: kvm2-home
placement:
zone: beelink
machineType: small
image: debian-13
port: 3128
cloudInit:
secretRef:
name: crawl-squid-cloud-init
attributes:
geo: home
purpose: crawl
tier: general
---
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: crawl-home-3
spec:
mode: Managed
provider: kvm2-home
placement:
zone: beelink
machineType: small
image: debian-13
port: 3128
cloudInit:
secretRef:
name: crawl-squid-cloud-init
attributes:
geo: home
purpose: crawl
tier: general
---
# A heavier member: two vCPUs and 2 GiB from the medium machine type, a
# different base image, and room for more concurrent leases. tier=heavy is
# what a crawler selects on to land here rather than on the general members.
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: crawl-home-heavy-1
spec:
mode: Managed
provider: kvm2-home
placement:
zone: beelink
machineType: medium
image: ubuntu-24.04
port: 3128
cloudInit:
secretRef:
name: crawl-squid-cloud-init
attributes:
geo: home
purpose: crawl
tier: heavy
maxLeases: 10
---
# A member that probes harder than the pool default (30s interval, 3 failures
# to go unhealthy): a tighter loop takes a flapping proxy out of rotation
# sooner, at the cost of more probe traffic. Everything under healthCheck is
# defaulted, so set only what you want to change.
#
# This one also runs squid on a non-default port, which is why it carries its
# own inline cloud-init: spec.port and the http_port in the config have to
# agree, and the shared Secret above pins 3128.
apiVersion: crawl.example.com/v1alpha1
kind: Proxy
metadata:
name: crawl-home-probe-fast-1
spec:
mode: Managed
provider: kvm2-home
placement:
zone: beelink
machineType: small
image: debian-13
port: 8080
cloudInit:
inline: |
#cloud-config
packages:
- squid
write_files:
- path: /etc/squid/conf.d/proxy-operator.conf
content: |
http_port 8080
http_access allow all
via off
forwarded_for off
runcmd:
- systemctl restart squid
healthCheck:
intervalSeconds: 10
timeoutSeconds: 3
failureThreshold: 2
attributes:
geo: home
purpose: crawl
tier: general
+58
View File
@@ -0,0 +1,58 @@
# Operating the operator
Helpers for running the operator day to day: log queries, one-off
inspections, and the small recipes that are easier to look up than to
reconstruct. Not a runbook — it assumes you already know what you are
looking for.
## Log queries
The manager logs one JSON object per line, so `jq` is the tool. The
`.msg` field is the discriminator: it is a fixed string per event, and
the variable parts live in their own fields rather than being
interpolated into the message.
### Libvirt RPCs, with timestamps
Both libvirt providers log one `"libvirt call"` per RPC, so this catches
`kvm` and `kvm2` alike:
```sh
jq -r 'select(.msg == "libvirt call") | "\(.ts) \(.rpc)"'
```
```text
2026-09-18T07:48:29Z virStoragePoolGetInfo
2026-09-18T07:48:29Z ConnectGetLibVersion
2026-09-18T07:48:29Z ConnectGetHostname
2026-09-18T07:48:29Z StoragePoolLookupByName
2026-09-18T07:48:29Z StoragePoolGetInfo
```
These lines are debug level, which is the binary's default
(`zap.Options{Development: true}` in `cmd/main.go`) — nothing to turn on.
They disappear if the Deployment has been given `--zap-log-level=info`,
which is the switch for outcomes only.
Piping from a live manager:
```sh
kubectl logs -n egress-proxies-operator-system \
deploy/egress-proxies-operator-controller-manager -f \
| jq -r 'select(.msg == "libvirt call") | "\(.ts) \(.rpc)"'
```
`jq` aborts on the first line that is not JSON, which happens when
something bypasses the structured logger — a panic trace, or output from
a tool wrapping the manager. Reading line-by-line with `fromjson?` skips
those instead:
```sh
jq -rR 'fromjson? | select(.msg == "libvirt call") | [.ts, .rpc] | @tsv'
```
Note that the connection-level events use their own `.msg` values
(`"libvirt host connected"`, `"kvm2 host checked"`), so they drop out of
the filters above. Widen to `select(.msg | startswith("libvirt"))` to
keep the connect lines — usually what you want when chasing a latency
spike rather than a call count.
@@ -0,0 +1,282 @@
# Execution: proxmox, a Proxmox VE provider built from the provider contract
Pairs with [docs/plans/2026-09-18-1502-proxmox-provider.md](../plans/2026-09-18-1502-proxmox-provider.md).
## Status
- [x] Phase 0 — Plan file, branch, host recon, fixtures
- [ ] Phase 1 — The spec (`docs/proxmox-provider-spec.md`)
- [ ] Phase 2 — Config block, registration, package skeleton
- [ ] Phase 3 — Extract `nocloud` and `keyedlock` from kvm2
- [ ] Phase 4 — Seam, HTTP client, decorator, fake
- [ ] Phase 5 — Get, ListByTag, task helpers
- [ ] Phase 6 — Create
- [ ] Phase 7 — Delete, Checker, CRD comment
- [ ] Phase 8 — Contract and observability sweep, integration test, testing.md
- [ ] Phase 9 — Deployment and documentation
- [ ] Phase 10 — Review, real-host validation, changelog, MR
## Phase 0 — Plan file, branch, host recon, fixtures
The plan was committed alone on `main` on 2026-09-18 (`708989c`, then
`2167819` retargeting it at PVE 9.2). `origin/main` had not moved in the
meantime, so the branch was cut straight from local `main`:
```bash
git fetch -q origin && git rev-list --left-right --count origin/main...main # 0 2
git switch -c feat/proxmox-provider
```
The host is **pv1-1**, a single node (no cluster peers), `pve-manager/9.2.20`,
kernel `7.0.14-17-pve`, QEMU 11.0. The user prepared the token
(`~/.config/proxmox-token`, privsep off) and the CA (`~/.config/proxmox-ca.pem`)
before the session.
**TLS: 192.168.2.8 is not in the node certificate.** The first probe failed
with curl exit 60. The CA is right (`openssl s_client -CAfile` verifies the
chain), but the leaf's SANs are `127.0.0.1, ::1, localhost, 192.168.0.8,
pv1-1, pv1-1.home.lab`, and neither name resolves on the workstation.
192.168.0.8 and 192.168.2.8 present the same certificate fingerprint: the
node sits on `vmbr0` (192.168.0.8/24, untagged) with VLAN interfaces
`vmbr0.2` (192.168.2.8/24) and `vmbr0.4` (192.168.4.8/24). All recon kept
full verification by connecting to 192.168.2.8 while verifying the
certificate as `pv1-1.home.lab`:
```bash
curl -s --resolve pv1-1.home.lab:8006:192.168.2.8 --cacert ~/.config/proxmox-ca.pem \
-H @<(printf 'Authorization: PVEAPIToken=%s\n' "$(tr -d '[:space:]' < ~/.config/proxmox-token)") \
https://pv1-1.home.lab:8006/api2/json/version
# {"data":{"release":"9.2","version":"9.2.20","repoid":"49318c671b82f31e"}}
```
Every capture went through a scratch helper wrapping exactly that call. It
passes the token through a file descriptor so it never reaches argv or
output, and writes `{"status","reason","body"}` to
`internal/provider/proxmox/testdata/<name>.json`, refusing to save a body
that contains `PVEAPIToken`.
A real NoCloud seed was used instead of the plan's 1 KiB placeholder, so the
agent could be installed and F21 captured from a live guest. No ISO tool is
installed locally, so a throwaway module in the scratchpad reused the
repository's `github.com/kdomanski/iso9660 v0.4.0` from the module cache and
copied kvm2's `guestAgentScript` verbatim:
```bash
GOFLAGS=-mod=mod GOPROXY=off GOSUMDB=off go run . proxy-reconaaaaaaaaaaa proxy-reconaaaaaaaaaaa-seed.iso
# 45056 bytes, ISO 9660 'cidata'
```
Write experiments ran against VMID 199999 (`proxy-reconaaaaaaaaaaa`), a
clone of template 9001. Sequence: validation error; clone; resources, config
write and start while the clone lock was held; status and agent while
running bare; stop; config read; clone onto the taken VMID; tag tests;
upload twice plus a bad name; attach `ide2`, set cores and memory; three
resizes; start and agent poll; suspend and resume; a clone onto a storage
without image support; stop; destroy with `purge=1&destroy-unreferenced-disks=1`;
destroy again; ISO delete.
### Facts F1 to F23 (cited as Evidence by the spec)
- **F1.** Errors carry the text twice: in the HTTP reason phrase and as the
body's `message`, which ends in `\n`. A 400 adds `errors`, a map from
parameter to message. A 401 has the reason phrase only and an empty body.
The client should use the trimmed body `message` and fall back to the
reason phrase. Headers are ordinary CRLF.
- **F2.** Missing VM, identical for `GET config`, `GET status/current` and
`DELETE`: `500 Configuration file 'nodes/pv1-1/qemu-server/999999.conf' does not exist`.
- **F3.** A start issued while the VM is clone-locked is **accepted** (200
with a UPID). The task log reads `trying to acquire lock... OK` and the VM
starts once the clone finishes. Setting a lock through the API was not
tried.
- **F4.** Clone onto a taken VMID, synchronous:
`500 unable to create VM 199999: config file already exists`.
- **F5.** Agent enabled but not running: `500 QEMU guest agent is not running`.
Agent not enabled in the config: `500 No QEMU guest agent configured`.
Stopped VM with `agent: 1`: `500 VM 199999 is not running`.
- **F6.** Not produced. `local-lvm` is thin and overprovisions, so
`quota: absent`.
- **F7.** Config write while clone-locked: `500 VM is locked (clone)`.
- **F8.** `/cluster/resources?type=vm` entries carry `vmid, name, node,
status, type, template, tags, maxcpu, maxmem, maxdisk, uptime` and more;
`lock` appears only when set. Status values seen: `running`, `stopped`,
and `unknown` during a clone. **Mid-clone the entry has no `name`**: only
`type, vmid, id, node, lock: clone, status: unknown`.
- **F9.** Hyphenated tags are **accepted** and stored as given. Uppercase is
folded (`Proxy_Operator` → `proxy_operator`), the separator is `;`, and
characters such as a space or `!` give
`400 … tags: invalid format - invalid characters in tag`. The datacenter
has no `tag-style` or `user-tag-access` set.
- **F10.** `description` on clone is accepted and survives verbatim,
newlines included. The 22-character `proxy-<base32>` name is accepted.
- **F11.** The clone's `meta` is copied from the template
(`creation-qemu=11.0.3,ctime=1790145676`, identical to 9001), so it says
nothing about when the clone was made. `written-at-create` is required.
- **F12.** Upload: multipart with field `content=iso` and the file part named
`filename`, carrying the target file name. Returns a UPID for an
`imgcopy` task. A name without `.iso` gives
`400 … filename: wrong file extension`. **Re-uploading an existing name
silently overwrites it** (the task log shows a plain `cp`).
`Datastore.AllocateTemplate` suffices.
- **F13.** Deleting a volume needs `Datastore.Allocate` on
`/storage/<id>`; without it the call gives
`403 Permission check failed (/storage/local, Datastore.Allocate)`. With it
(on `proxy-seeds`), the delete **runs as an `imgdel` task** and returns a
UPID. Deleting a volume that no longer exists is also accepted and its
task still ends `OK`; the log reads `disk image '…' does not exist`, then
`Removed volume`. Seed cleanup is idempotent with no message matching.
- **F14.** Stopped: `status: stopped, qmpstatus: stopped`. Paused:
`status: running, qmpstatus: paused` in `status/current`, while
`/cluster/resources` shows plain `running`.
- **F15.** A clone onto a storage without image support is rejected up
front (`400 … storage 'local' does not support vm images`) and leaves no
VM. A clone failing mid-task was not observed.
- **F16.** Task status fields: `status` (`running`, `stopped`), `exitstatus`
(`OK` or the error text), `type, id, node, user, tokenid, upid, starttime,
pid, pstart`. A full clone of the 3 GiB template onto `local-lvm` finished
within about two seconds.
- **F17.** The planned role, granted on `/`, covered clone, config, resize,
start, stop, suspend, agent read, upload and destroy. **Missing:
`Datastore.Allocate` for deleting the seed ISO.** `VM.Config.Cloudinit` is
not needed for the `ide2` write. `SDN.Use` was held but never exercised,
because no call rewrote `net0`.
- **F18.** HTTP/1.1 only: `curl --http2` negotiates `HTTP/1.1`.
- **F19.** Template 9001 (`debian-13-template`): `scsi0` on `local-lvm`,
`boot: order=scsi0`, `ide2` free, `agent: 1`, OVMF, q35, `net0` untagged on
`vmbr0`. **Attaching `ide2` makes Proxmox append it to the boot order**
(`order=scsi0;ide2`). Template 9000 (Ubuntu) is unsuitable: it has a
Proxmox cloud-init drive on `ide2` and VLAN tag 4 on `net0`.
- **F20.** Resize **runs as a task** (returns a UPID). The same size is an
`OK` no-op; shrinking fails inside the task with `exitstatus`
`shrinking disks is not supported`.
- **F21.** Agent response: `result[]` of `{name, ip-addresses[{ip-address,
ip-address-type}]}`. The live guest reported `lo` and `ens18` with
`192.168.0.150`, 50 seconds after start, via DHCP on untagged `vmbr0`.
This proves the NoCloud-seed-on-`ide2` route end to end on this host.
- **F22.** A wrong secret and an unknown token id both give
`401 Authentication failed!`; no credentials gives `401 No ticket`.
- **F23.** Uploads are staged in `/var/tmp/pveupload-*` and then copied to
`/var/lib/vz/template/iso/`, so the tmpfs `/tmp` is not involved.
### Corrections to the plan that Phase 1 must fold into the spec
1. **Check `lock` before the name.** A mid-clone VM has no name in
`/cluster/resources` (F8), so name-first logic turns a clone in progress
into NotFound. Get, Delete and Create's lookup must treat a locked or
nameless entry at our VMID as in-progress (Transient or wait), never
absent.
2. **Inspect a taken VMID before probing forward.** After a crash mid-clone,
the retry's name lookup misses the nameless VM, the clone onto the
deterministic VMID fails with F4, and forward probing would clone a
duplicate. On F4, read that VMID: `lock: clone` means wait for it, and our
UID in the description means adopt it. Probe forward only for a VM that
is demonstrably someone else's.
3. **A start queues behind a lock** instead of failing (F3).
4. **Resize is asynchronous** (F20); wait on its task.
5. **Drop delete-before-upload** for the seed: upload overwrites (F12).
**Seed deletion is a task** (F13): wait on it. A repeat delete succeeds,
so Delete needs no absent-volume matcher.
6. **Get reads `status/current`**, not only `/cluster/resources`, to tell
paused from running (F14).
7. **Tag rationale**: hyphens are accepted on 9.2.20 (F9). `proxy_operator`
stays, since it is valid everywhere, but the claim that hyphens are
rejected is removed.
8. **The boot-disk logic must skip `ide2`**, which Proxmox adds to the boot
order (F19). The plan's rule already excludes it; add a test with
`order=scsi0;ide2`.
9. **Three agent messages, not one** (F5): `QEMU guest agent is not running`
(booting, or the install failed), `No QEMU guest agent configured` (our
`agent=1` was removed), and `VM <vmid> is not running` (stopped between
the status read and the agent call). The spec decides each class; none
may become NotFound.
10. **Internet on the proxy network is a hard prerequisite**, twice over: the
vendor-data agent install needs a package mirror, and the proxy itself
needs egress. The guide says so, and recommends baking
`qemu-guest-agent` into the template with `installGuestAgent: false` to
drop the mirror dependency from IP discovery.
### Decisions made with the user in Phase 0
- **Seeds go to a dedicated directory storage**, `proxy-seeds`, with ISO
content only. The token gets `Datastore.Allocate` there and nowhere else,
so it can delete seeds but never backups or other ISOs on `local`. The
sample config's `isoStorage` becomes `proxy-seeds`. The guide must say that
an ACL on a more specific path replaces the inherited one for that user,
so the role granted on `/storage/proxy-seeds` must repeat
`Datastore.AllocateTemplate`, `AllocateSpace` and `Audit` alongside
`Allocate`.
- **Proxies land on a VLAN** set in config. `ProxmoxConfig` gains an
optional `vlanTag` (1 to 4094), applied when Create rewrites `net0`
together with the optional bridge override, keeping the MAC. Unset leaves
`net0` as cloned. This is the first call that exercises `SDN.Use` (F17).
- **The API URL is `https://192.168.0.8:8006`**, the address in the node
certificate, so `caFile` verifies with no provider change. No
`tlsServerName` option. The operator's cluster must be able to reach
192.168.0.x.
### Seed storage and VLAN 4
The user created `proxy-seeds` (`dir`, ISO content only), granted the token
`Datastore.Allocate`, `AllocateSpace`, `AllocateTemplate` and `Audit` on
`/storage/proxy-seeds` through a role `ProxyOperatorSeeds`, and freed the
leftover recon ISO on `local`. An upload to `proxy-seeds` and two deletes of
a throwaway ISO there produced `content_delete_response.json`,
`content_delete_again_response.json` and `task_status_volume_missing.json`.
The user gave the VLAN as "vlan 4, bridge: vmbr0.4@vmbr0". That is the
host's own VLAN interface name as `ip link` prints it; a VM NIC cannot
attach to it because it is not a bridge. On the VLAN-aware `vmbr0` the
Proxmox form is `bridge=vmbr0,tag=4`, which is what template 9000 uses, so
the provider's `vlanTag: 4` appends `tag=4` to the cloned `net0`.
A second clone of 9001 at VMID 199999 got its seed from `proxy-seeds` and
`net0` rewritten from `virtio=BC:24:11:EE:48:83,bridge=vmbr0` to
`virtio=BC:24:11:EE:48:83,bridge=vmbr0,tag=4`. **The write succeeded, so the
token's `SDN.Use` on `/` covers a VLAN tag on `vmbr0`**
(`set_config_vlan_response.json`).
**The first VLAN 4 attempt never produced an agent** in seven minutes of
polling (every 13 seconds, 33 polls). The only signal was the agent call, so
the cause could not be seen from the API. A debug seed added a `recon` user
(user-data only, with a new instance id so cloud-init re-ran), and the user
logged in on the console: DHCP worked, but VLAN 4 had no route to the
internet because of a routing misconfiguration on the Proxmox host, so the
vendor-data `apt-get install qemu-guest-agent` could not run. The user fixed
the routing.
**Rerun from scratch after the fix**, same sequence the provider will use
(destroy and seed delete first, then a fresh clone and seed, `instance-id`
equal to the name):
| Step | Elapsed |
|---|---|
| clone task done | 4 s |
| seed upload task done | 4 s |
| config (`ide2`, `net0` with `tag=4`, tag) | 4 s |
| start task done | 5 s |
| agent reports `192.168.4.147` | 47 s after start |
The guest was reachable from the workstation (ping, TCP 22), as the contract
requires of a proxy address. Stopping it gave the F5 stopped-VM fixture;
destroy and seed delete then left the host with only its original VMs 100,
9000 and 9001, no ISOs on `local` or `proxy-seeds`, and no `local-lvm`
volumes of 199999. The capture is `agent_network_get_interfaces_vlan.json`.
### Worth noting
- The token file is mode 664; 600 would be tighter.
- Fixtures: 48 files, all valid JSON, none containing `PVEAPIToken`.
Several names differ from the plan's list because what they captured
differed from what was expected: `start_while_clone_locked_response.json`
(the start succeeded), `task_status_stopped_error.json` (from the shrink,
not a start), `upload_overwrite_response.json` (success, not an error),
`resize_same_size_response.json` and `resize_shrink_response.json`
(UPIDs), `err_forbidden_volume_delete.json` (the 403 from F13).
`err_no_space.json` was not produced (F6).
- The host is back to its original state; see the table above.
- The scratch seed builder (`seedgen` in the session scratchpad) took the
user-data file and instance id as arguments by the end, for the debug
login. It is not part of the repository; Phase 3's `nocloud` package
replaces it.
- Time to IP was about 50 s on both networks, with the agent installed at
first boot. Budgets in the plan (running wait 60 s for the VM state, the
agent discovered by `Get` polling) are consistent with this.
@@ -0,0 +1,875 @@
# Plan: proxmox, a Proxmox VE provider built from the provider contract
**Created:** 2026-09-18 15:02
## Context
The provider contract (`docs/provider-contract.md`), the spec template
(`docs/provider-spec-template.md`) and the kvm2 build proved the workflow:
spec first, then implementation against the contract, one commit per step.
This plan adds a third VM-style backend, `proxmox`, on the Proxmox VE 9.x
REST API at `https://<host>:8006/api2/json`. The user has a real host at
192.168.2.8 running **9.2.20** (Debian 13 Trixie, QEMU 11) and prepares the
API token and a cloud-init-capable template before Phase 0 (checklist
below). Everything below is written against 9.2; where 8.x differed the
difference is called out, because the appendix and the guide must name the
version they were proven on.
The implementation runs in sessions whose context gets compacted, so every
phase is self-contained: it names what to read first, what it creates or
edits, the test names it must add, a `Verify:` clause, a "Done when" line
and a commit subject. A fresh session reading only this plan, the spec
(`docs/proxmox-provider-spec.md`, from Phase 1 on) and the execution log's
checklist (`docs/plans-executions/2026-09-18-1502-proxmox-provider.md`) must be able to
pick up where the last one stopped.
**Resumption protocol, run at the start of every phase.** Read this plan's
Context and the phase entry; read the spec; read the execution log's
checklist; run `git branch --show-current` (must be `feat/proxmox-provider`
from Phase 0 on), `git status --short` and `git log --oneline -8`. The first
unchecked box is the phase to do. If its files already exist partially, its
"Done when" line says whether it counts as done; finish it, never redo it. A
phase is finished only when its "Done when" holds and its execution-log entry
(a few sentences, exact commands, "worth noting", the box ticked) is
committed together with the code. Follow `CLAUDE.md` for the log format.
## Before Phase 0: what the user prepares on 192.168.2.8
Run as root on the Proxmox node. **`VM.Monitor` was removed in PVE 9.0**, so
the agent read rests on `VM.GuestAgent.Audit` alone and the role below must
not mention `VM.Monitor`. Drop any privilege `pveum` still rejects and note
it for F17.
```bash
pveum user add proxy-operator@pve --comment "egress-proxies-operator"
pveum role add ProxyOperator -privs "VM.Allocate VM.Clone VM.Audit VM.Config.CDROM VM.Config.CPU VM.Config.Memory VM.Config.Disk VM.Config.Network VM.Config.Options VM.PowerMgmt VM.GuestAgent.Audit Datastore.Audit Datastore.AllocateSpace Datastore.AllocateTemplate Sys.Audit SDN.Use"
pveum aclmod / -user proxy-operator@pve -role ProxyOperator
pveum user token add proxy-operator@pve operator --privsep 0
# The secret is printed once. Save it on the workstation as one line:
# proxy-operator@pve!operator=<secret>
# in ~/.config/proxmox-token (mode 0600). The same file becomes the
# Kubernetes Secret later.
cp /etc/pve/pve-root-ca.pem /tmp/ # copy to the workstation as ~/.config/proxmox-ca.pem, or use insecureSkipTLSVerify
```
A cloud-init template without a Proxmox cloud-init drive (the provider puts
its own NoCloud ISO on `ide2`). Debian 13's BIOS boot path did not boot
under kvm2, so the template boots UEFI:
```bash
wget https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
qm create 9000 --name debian-13-template --memory 2048 --cores 2 --ostype l26 \
--machine q35 --bios ovmf --efidisk0 local-lvm:1,efitype=4m,pre-enrolled-keys=0 \
--scsihw virtio-scsi-pci --net0 virtio,bridge=vmbr0 \
--serial0 socket --vga serial0 --agent 1
qm set 9000 --scsi0 local-lvm:0,import-from=/root/debian-13-generic-amd64.qcow2
qm set 9000 --boot order=scsi0
qm template 9000
```
Facts the user hands over at Phase 0: the token file path, the CA file path
(or "insecure"), the node name, the template VMID, the ISO-content storage
id (usually `local`), the VM disk storage (`local-lvm`), the bridge, and
whether the operator's cluster can route to that bridge's LAN. Everything
else Phase 0 reads from the API.
## Design decisions made with the user (not re-opened)
- **Backend and client.** Proxmox VE 9.x REST API, JSON, HTTP/1.1 only
(pveproxy offers no HTTP/2 on any release; only Proxmox Backup Server
does).
Thin client on `net/http` (`client-kind: thin-client`), no SDK, no new
dependency. Auth header
`Authorization: PVEAPIToken=<user>@<realm>!<tokenid>=<secret>`, read from a
mounted token file on every request (rotation free). TLS: `caFile` or
`insecureSkipTLSVerify` (warned at startup). Child spans from a parent-gated
otelhttp transport: `tracing.RestConfigWrapper()(base)` returns a plain
`http.RoundTripper` and works as is (`child-spans: transport-wrapper`).
- **Image source.** Clone a prepared template. Config catalogue
`templates: {<key>: {vmid: 9000}}`; `Placement.Image` is a key.
`Placement.Zone` is a node name from `nodes: [..]` (optional with one node).
`Placement.MachineType` is a key of `machineTypes: {<key>: {cores, memoryMiB,
diskGiB}}`; `diskGiB` (optional) grows the boot disk via `PUT .../resize`.
`Placement.Region` is ignored (documented).
- **Identity.** `primary-key: allocated-id`. VMID derived deterministically
from the Proxy UID (FNV-1a 64 into `vmidRange: {min, max}`, default
100000 to 199999) with forward probing on collision; a clone whose `newid`
is taken fails with a 500, which is the allocate-then-verify step. Create
first looks the name up in `GET /cluster/resources?type=vm`; an existing VM
with that name must carry our mark and a matching or empty UID, else
Permanent. providerID is location-free: `vms/<vmid>/<name>` (VMs migrate;
the node is resolved from `/cluster/resources` on every call). Get and
Delete verify the VM at that VMID has that name and our mark; a different
name means our VM is gone and the VMID was reused: Get returns NotFound,
Delete returns nil without touching it.
- **Cloud-init.** NoCloud seed ISO built in-process by reusing kvm2's seed
code, extracted into a shared package `internal/provider/nocloud` (its own
phase). Uploaded to `isoStorage` (default `local`) on the target node via
`POST /nodes/{node}/storage/{storage}/upload` (multipart, `content=iso`,
`filename=<name>-seed.iso`, streamed through `io.Pipe`, returns a UPID),
attached as `ide2: <storage>:iso/<name>-seed.iso,media=cdrom`. The template
must have no Proxmox cloudinit drive on ide2 (documented prerequisite).
Vendor-data installs qemu-guest-agent like kvm2 (`installGuestAgent`,
`vendorData`).
- **GC mark.** `gc-mark: written-metadata` in the VM `description`, which the
clone call accepts (`tags` is not a clone parameter, so tags would be a
mark window). Content, one per line: `proxy-operator-managed=true`,
`proxy-operator-uid=<uid>`, `proxy-operator-created-at=<RFC3339>`.
`created-at: written-at-create`. The config step after the clone also sets
tag `proxy_operator` for UI filtering only. **Underscore, not hyphen:**
current PVE tag validation accepts only lowercase letters, digits and
underscore, and a hyphen is rejected; the underscore form is valid under
both the strict and the permissive reading, so it is used regardless of
what F9 finds. ListByTag: one
`GET /cluster/resources?type=vm`; candidates are entries with tag
`proxy_operator` or name prefix `proxy-`; a `GET /nodes/{node}/qemu/{vmid}/config`
per candidate for the description; unmarked skipped; one unreadable VM
skipped with a V(1) line unless ctx ended; the resources call failing fails
the whole list (`list-partial: fail-whole`).
- **Create** (`create-return: blocking`, `provisioning-state: create-blocks`,
`async-ops: task-handle` and `backend-lock`), every step idempotent, in
this order: validate placement (Permanent before any call); resources
lookup by name (adopt or allocate); `POST /nodes/{tmplnode}/qemu/{tmplvmid}/clone`
`{newid, name, description, full=1, target?, storage?}`; wait the task
(`exitstatus != OK` is Transient with the message; a VM with `lock=clone`
on retry is waited on); upload the seed (always rebuilt, delete then
upload; before the config step because `ide2` may refuse a volume that
does not exist yet); config (`cores`, `memory`, `net0` bridge override if
configured, `agent=1`, `tags`, `ide2` seed); optional resize;
`POST .../status/start` and wait; poll `status/current` until `running`
(capped); return `vms/<vmid>/<name>`. Retry: name found running returns
the ID; found stopped with our mark continues from the seed step; found
locked waits.
- **Get.** Resources lookup by vmid: absent is NotFound; name mismatch or no
mark is NotFound (logged); `lock` set is Transient; `running` reads
`GET /nodes/{node}/qemu/{vmid}/agent/network-get-interfaces`, first
routable IPv4 excluding loopback and link-local gives Running, else
Provisioning (the agent not running is a 500 "QEMU guest agent is not
running", which is Provisioning, not an error); `stopped` is Stopped;
`unknown` is Transient; `qmpstatus=paused` is Stopped; anything else is
Stopped. UID and CreatedAt come from the description on every call, so a
Get is three calls: resources, config, agent.
- **Delete** (`delete-return: waits`, `secondary-resources: explicit`):
resolve by vmid via resources; absent or foreign is nil (logged); running
gets `POST status/stop` and a task wait; then
`DELETE /nodes/{node}/qemu/{vmid}?purge=1&destroy-unreferenced-disks=1` and
a task wait; then delete the seed ISO
`DELETE /nodes/{node}/storage/{storage}/content/{storage}:iso/<name>-seed.iso`
tolerating absence; a locked VM is Transient. Called again mid-delete it
repeats the same tolerant steps. GC blind spot: seed ISOs without a VM
(documented; the ISO name carries the instance name).
- **Errors**, one `classify(op, err)` over `*apiError{Status, Message, Errors}`:
401, 403, 400 and 404 are Permanent (404 means the path never exists;
NotFound comes only from the list-and-miss on resources and from the 500
"does not exist" message in Get and Delete, matched against captured
fixtures); 500 "does not exist" is NotFound (Transient inside Create); 500
"VM is locked" is Transient; 500 "already exists" is handled by the
allocator, otherwise Transient; 500 "not enough free space" or "no space
left" is QuotaExceeded once a real message is captured, else `quota:
absent`; other 5xx, 595, 596, network and ctx errors are Transient;
anything not an apiError is Transient. Error text: Proxmox puts most of it
in the HTTP status reason phrase (`HTTP/1.1 500 Configuration file '...'
does not exist`) and newer releases also in the body's `message`; the
client reads both, body first.
- **Checker.** One target per configured node, in config order, concurrently,
on the same http client: `GET /version` (Details `pveVersion`),
`GET /nodes/{node}/status` (Details `kernel`),
`GET /nodes/{node}/storage/{isoStorage}/status` (Details `isoStorage`,
`isoStorageAvailGiB`), template existence from `GET /cluster/resources?type=vm`
(the `template` flag, any node; Details `templates` as `<found>/<configured>`).
Reasons: the shared ones plus `TemplateNotFound` and `StorageNotFound`.
The `status.type` comment in `api/v1alpha1/providerhealth_types.go` gains
`proxmox`; `make manifests`.
- **Timeouts** (`timeouts: per-call`): request timeout 30s
(`requestTimeoutSeconds`); task-wait caps clone 10m, upload 2m, start and
stop 2m, destroy 5m, running wait 60s; all in a `budgets` struct
overridable in tests. A cancelled ctx cancels that request only; the
connection pool is unaffected. Per-name keyed lock for Create and Delete
(`internal/provider/keyedlock`, extracted from kvm2); Get and ListByTag
lock-free.
- **Logging** (`logging: tiered`): `logf.FromContext(ctx).WithName("proxmox").WithValues("provider", name)`,
`vmid` and `name` on instance lines; V(1) outcomes and one
`proxmox api call` line per seam call from the `observed` decorator; V(2)
request params and response summaries; never the token, never cloud-init,
never the multipart body, never `description` at V(1); no V(5). The
decorator logs, the transport traces: no `proxmox.<Method>` spans, so
nothing is double-counted.
- **Config writes** use `PUT .../config` (synchronous; the VM is stopped at
that point). `resize` is skipped when the boot disk's current `size=` is
at or above the target. Boot disk: first disk key in `boot: order=...`
that is not `net*` and not the ide2 cdrom, else `bootdisk`, else the first
of `scsi0`, `virtio0`, `sata0`, `ide0`; none is Permanent.
- **A UPID names its node** (`UPID:<node>:...`); task polling uses the node
parsed from the UPID.
- **Two VMs with our name** (Proxmox does not enforce unique names) make
Create and Get return Permanent naming both VMIDs; an admin resolves it.
- **Seam.** `api` interface with one method per endpoint (table below),
`httpAPI` production adapter, `fakeAPI` in `fake_test.go` keeping VMs, tasks
and ISOs in memory with lock simulation, task polls, per-method errors and
block channels, and a call log.
- **Integration test** `proxmox_integration_test.go` behind
`//go:build integration`, `PROXMOX_TEST_URL`, `PROXMOX_TEST_TOKEN_FILE`,
`PROXMOX_TEST_NODE`, `PROXMOX_TEST_TEMPLATE_VMID`, `PROXMOX_TEST_ISO_STORAGE`,
optional `_CA_FILE`, `_INSECURE`, `_BRIDGE`, `_STORAGE`, `_VMID_MIN`,
`_VMID_MAX`, `_TIMEOUT`, `_KEEP`, `_LOG_LEVEL`; `make test-integration-proxmox`.
- **Deployment.** Secret `proxmox-token` mounted optional at
`/etc/proxy-operator/proxmox/token`, `make proxmox-token-secret`, commented
entries in `config/manager/providers_config.yaml` and
`config/samples/providers-config.yaml`, `config/samples/proxy_proxmox.yaml`,
`docs/proxmox-provider.md`, `providers-existing.md` column and section,
`architecture.md` rows, README row, `testing.md` section, CHANGELOG after
confirmation. Branch `feat/proxmox-provider`, MR via the Gitea API (no `tea`).
## Backend facts Phase 0 must verify on 192.168.2.8 (numbered for citation)
- F1. Where error text lives: status reason phrase vs. JSON `message`; the
`errors` map shape on a 400.
- F2. Exact 500 message for a missing VM (`GET /nodes/{node}/qemu/999999/config`).
- F3. Exact 500 message for a locked VM (`POST status/start` on a VM with
`lock` set), and whether `PUT config` with `lock=backup` sets the lock and
`delete=lock` clears it through the API, or `qm unlock` is needed.
- F4. Exact 500 message when `newid` is in use on clone.
- F5. Exact 500 message for the agent not running, and for the agent call
on a stopped VM.
- F6. Exact message for no space, if it can be produced cheaply; otherwise
`quota: absent`.
- F7. Whether `PUT .../config` is refused while `lock=clone`, and with which
message.
- F8. `/cluster/resources?type=vm` fields present: `vmid`, `name`, `node`,
`status` values (`running`, `stopped`, `unknown`), `type` (`qemu`, `lxc`),
`lock`, `tags` (separator), `template`; whether `lock` appears there during
a clone.
- F9. Tag rules on 9.2: confirm a hyphen is rejected and `proxy_operator`
accepted, plus case folding, length, and the `tags` separator on
`PUT config` and in `/cluster/resources`. The underscore form stays either
way; this fact only decides what the guide tells users about their own
tags.
- F10. `description` on `POST .../clone`: accepted, survives verbatim
(newlines), max length; whether `name` accepts the 22-character
`proxy-<base32>` form.
- F11. `meta` in the cloned config: is `ctime` the clone time (evidence only;
`written-at-create` stays the profile).
- F12. Upload endpoint: field names (`content`, `filename`, file part name
`filename`), the filename rule (`.iso` suffix, no slashes), the response
(UPID), the privilege it needs, and whether re-uploading an existing name
is refused (then delete-then-upload) or overwrites. On 9.x `content`
accepts `iso`, `vztmpl` or `import` and still refuses `snippets`, which is
why the seed travels as an ISO; confirm `iso` is what an `.iso` needs.
- F13. `DELETE /nodes/{node}/storage/{storage}/content/{volid}`: response
shape (UPID or null) and the message for a missing volume.
- F14. `status/current` on a stopped VM: is `qmpstatus` present; on a paused
VM: `status=running`, `qmpstatus=paused`.
- F15. `POST .../clone`: whether a failed clone destroys the partial VM by
itself, and what a VM interrupted mid-clone looks like (config present,
`lock=clone`).
- F16. Task status shape: `status` (`running`, `stopped`), `exitstatus`
(`OK` or the error string), and how long a full clone of the template
takes.
- F17. Privileges: which of the prepared list the token really needs, in
particular the exact ACL path that satisfies `SDN.Use` for the `net0`
write (the 9.x docs describe it as "access SDN vnets and local network
bridges" without spelling out the path for a plain bridge), and
`VM.GuestAgent.Audit` for the agent read. Also whether the config step
needs `VM.Config.Cloudinit` even though only `ide2` is written.
- F18. HTTP/2 is not offered by pveproxy on any release (documented), so the
client disables it; confirm with `curl --http2 -I` that the connection
falls back to HTTP/1.1.
- F19. The template's disk key (`scsi0` or `virtio0`), its `boot` line, that
ide2 is free and no `cloudinit` drive exists.
- F20. `resize` on a stopped VM: request shape, the message when the target
is smaller or equal, and that it is synchronous.
- F21. Agent response shape: `result[].name`, `result[]["ip-addresses"][]`
with `ip-address`, `ip-address-type`.
- F22. The 401 body and reason phrase on 9.2. PVE 9.2 unified the
authentication-failure message so a failed login no longer leaks whether
the user exists, so any 401 text remembered from 8.x is wrong. Capture it
fresh into `err_unauthorized.json`.
- F23. Where the upload endpoint stages the request body, given that PVE 9
moved the node's `/tmp` to a tmpfs capped at half of RAM and cleans `/tmp`
and `/var/tmp` periodically. A seed ISO is tens of KiB so this cannot
bite in practice, but the observation belongs in the guide next to the
size limit.
## Proxmox endpoint map for the seam
| `api` method | HTTP |
|---|---|
| `Version(ctx)` | `GET /version` |
| `NodeStatus(ctx, node)` | `GET /nodes/{node}/status` |
| `StorageStatus(ctx, node, storage)` | `GET /nodes/{node}/storage/{storage}/status` |
| `ClusterResources(ctx)` | `GET /cluster/resources?type=vm` |
| `VMConfig(ctx, node, vmid)` | `GET /nodes/{node}/qemu/{vmid}/config` |
| `SetVMConfig(ctx, node, vmid, params)` | `PUT /nodes/{node}/qemu/{vmid}/config` |
| `Clone(ctx, node, vmid, cloneParams)` | `POST /nodes/{node}/qemu/{vmid}/clone` |
| `Resize(ctx, node, vmid, disk, size)` | `PUT /nodes/{node}/qemu/{vmid}/resize` |
| `Start(ctx, node, vmid)` | `POST /nodes/{node}/qemu/{vmid}/status/start` |
| `Stop(ctx, node, vmid)` | `POST /nodes/{node}/qemu/{vmid}/status/stop` |
| `VMStatus(ctx, node, vmid)` | `GET /nodes/{node}/qemu/{vmid}/status/current` |
| `Destroy(ctx, node, vmid)` | `DELETE /nodes/{node}/qemu/{vmid}?purge=1&destroy-unreferenced-disks=1` |
| `TaskStatus(ctx, node, upid)` | `GET /nodes/{node}/tasks/{upid}/status` |
| `AgentNetworkInterfaces(ctx, node, vmid)` | `GET /nodes/{node}/qemu/{vmid}/agent/network-get-interfaces` |
| `UploadISO(ctx, node, storage, filename, r, size)` | `POST /nodes/{node}/storage/{storage}/upload` (multipart) |
| `DeleteVolume(ctx, node, storage, volid)` | `DELETE /nodes/{node}/storage/{storage}/content/{volid}` |
## Package layout (`internal/provider/proxmox/`)
`proxmox.go` (`Provider`, `New`, `newProvider`, `withDefaults`, `logger`,
`node`, `wrapErr`, `lockName`, `providerOption`, `budgets`), `api.go` (the
`api` interface and DTOs `vmResource`, `vmConfig`, `vmStatus`, `taskStatus`,
`agentInterface`, `storageStatus`, `nodeStatus`, `versionInfo`,
`cloneParams`), `client.go` (`httpAPI`, `newHTTPAPI`, `do`, `readToken`,
`decodeConfig`), `errors.go` (`apiError`, `classify`, `permanentf`,
`transientf`, `isNotExist`, `isLocked`, `isAlreadyExists`,
`isAgentNotRunning`, `isNoSpace`), `id.go` (`formatProviderID`,
`parseProviderID`, `vmidFor`), `mark.go` (`instanceMark`,
`formatDescription`, `parseDescription`), `state.go` (`mapState`,
`firstIPv4`), `observe.go` (`observed`, `observe`), `task.go` (`parseUPID`,
`waitTask`, `waitUnlocked`, `waitRunning`), `seed.go` (`seedISOName`,
`seedVolID`, `uploadSeed`), `create.go`, `get.go`, `delete.go`, `list.go`,
`check.go`, plus `fake_test.go`, `provider_test.go`,
`proxmox_integration_test.go`, `testdata/*.json`. Shared:
`internal/provider/config_proxmox.go`, `internal/provider/nocloud/`,
`internal/provider/keyedlock/`.
Reused as-is: `provider.NameFromUID`, `provider.Wrap`, the `Err*` sentinels,
`provider.Label*` constants, `provider.Reason*` constants,
`tracing.RestConfigWrapper`, `tracing.WithTracerProvider`, kvm2's test
idioms (`fixture`, `assertProviderError`, `logCapture`).
## Steps (one commit per phase, on `feat/proxmox-provider` off `main`)
- **Phase 0 (orchestrator with the user): plan file, branch, host recon,
fixtures.** Read first: contract §5, §4.9, §7;
`docs/plans-executions/2026-09-16-1333-kvm2-provider.md` Step 0 entry.
Do, in this order: `git switch main && git pull --ff-only`; copy this plan
to `docs/plans/2026-09-18-1502-proxmox-provider.md` with the real timestamp
(`date "+%Y-%m-%d-%H%M"`), commit it alone on `main`
(`docs: plan the proxmox provider`); create `feat/proxmox-provider`. Ask
the user for the hand-over facts listed above. Then, with the user's
go-ahead (each `curl` is a permission prompt; read-only ones first, one at
a time), capture F1 to F21 with
`curl -s --cacert "$CA" -D /tmp/h.txt -o /tmp/b.json -w '%{http_code}\n' -H "Authorization: PVEAPIToken=$(cat "$TOKEN_FILE")" https://192.168.2.8:8006/api2/json/<path>`,
storing each response as `internal/provider/proxmox/testdata/<name>.json`
in the shape `{"status": <code>, "reason": "<status line after the
code>", "body": "<raw body>"}`: `version.json`, `node_status.json`,
`storage_status.json`, `cluster_resources.json`,
`vm_config_template.json`, `vm_config_clone.json`,
`status_current_running.json`, `status_current_stopped.json`,
`agent_network_get_interfaces.json`, `task_status_running.json`,
`task_status_stopped_ok.json`, `task_status_stopped_error.json`,
`upload_response.json`, `content_delete_response.json`,
`err_vm_missing.json`, `err_locked.json`, `err_newid_in_use.json`,
`err_agent_not_running.json`, `err_agent_vm_stopped.json`,
`err_unauthorized.json` (a wrong secret), `err_forbidden.json` (a path
the token lacks), `err_bad_param.json` (`newid=abc`), `err_no_space.json`
(only if F6 is cheap), `err_volume_missing.json` (F13). The write
experiments: one clone of the template into the top of the VMID range
(`newid=199999`, a multi-line `description`, a `name` of the `proxy-`
form), a config read of the clone (F10, F11, F19), a `PUT config` while
it is locked if timing allows (F7), an upload of a 1 KiB
`recon-seed.iso` (F12) and its delete (F13), a resize attempt (F20), a
start, an agent read (F5, F21), a stop, then destroy with
`purge=1&destroy-unreferenced-disks=1`; a final resources read shows
nothing remains. Redact nothing but the token. Write the answers to F1 to
F21 and the privilege outcome into the execution log's Phase 0 entry, one
line per fact; Phase 1 cites them as Evidence. Create the execution log
`docs/plans-executions/2026-09-18-1502-proxmox-provider.md` with the checklist (Phase
0 to Phase 10) and this entry.
Verify: `ls internal/provider/proxmox/testdata | wc -l` is at least 22;
`grep -rl 'PVEAPIToken' internal/provider/proxmox/testdata` is empty;
every fixture parses as JSON; the host shows no VM in the range and no
`recon-seed.iso`.
Done when: the plan commit is on `main`, the branch exists, the fixtures
and the execution log with F1 to F21 are committed. Commit: `proxmox:
captured API fixtures and host facts from 192.168.2.8`.
- **Phase 1 (orchestrator): the spec.** Read first: contract §2, §4, §5,
§6, §11, §12; `docs/provider-spec-template.md` end to end;
`docs/kvm2-provider-spec.md` for the level of detail; this plan's
decisions; the Phase 0 log entry. Create `docs/proxmox-provider-spec.md`
answering all 24 entries: 1 `vms/<vmid>/<name>`, strict parsing, the
node-name and storage-id regexps; 2 `allocated-id`, `vmidFor(uid, rng,
attempt)`, the two-VMs-with-our-name rule, the adoption guard; 3 the
placement table (Region ignored); 4 the template catalogue and the "add a
key, never edit" warning; 5 the seed ISO via `nocloud` and the ide2
prerequisite; 6 `machineTypes` with `diskGiB`; 7 `provisioning-window`,
`discovered` via the guest agent, the IPv4 filter, `Port` unused; 8
`written-metadata` in `description` plus the tag, `written-at-create`, F9
to F11 as Evidence; 9 the state table, `create-blocks`; 10 the ordered
Create steps with what a retry finds at each, `task-handle` plus
`backend-lock`, the budgets; 11 the Delete order, the ISO blind spot,
node-local ISO storage after a migration; 12 `fail-whole`, the candidate
rule, one config read per candidate; 13 the classify table with the F1 to
F6 messages, `not-found: error-code` plus list-and-miss on resources
(allowed because offline nodes' guests appear as `unknown`, not absent);
14 `mounted-files` (Secret `proxmox-token`, key `token`, path
`/etc/proxy-operator/proxmox/token`), `ca-file` or
`insecureSkipTLSVerify`, the privilege list from F17, rotation free; 15
the config table (Phase 2's struct); 16 the Checker; 17 `thin-client`,
`transport-wrapper`, the seam's method list; 18 the budgets table; 19 the
keyed lock per name; 20 the log keys; 21 the §11 list with the test names
from Phases 2 to 8 (copy from this plan); 22 the deployment list; 23 the
docs list; 24 the accepted limits (blocking Create up to the clone budget,
ISO blind spot, VMID range shared with humans, node-local ISO storage, no
adoption of foreign VMs, two-installs GC caveat, `unknown` status while a
node is down).
Verify: `grep -c '^\*\*Answer:\*\*$' docs/proxmox-provider-spec.md` is 0;
every axis code used appears in contract §4.20; Evidence lines cite F
numbers; `grep -c '—' docs/proxmox-provider-spec.md` is 0.
Done when: the spec is committed with all 24 entries and the Phase 1 box
ticked. Commit: `docs: proxmox provider spec`.
- **Phase 2 (implementer): config block, registration, package skeleton.**
Read first: spec entries 1, 2, 3, 6, 8, 9, 13, 15; contract §10, §5, §6;
`internal/provider/config.go`, `config_kvm.go`, `config_test.go`,
`config_kvm_test.go`; `internal/provider/kvm2/{kvm2.go,id.go,metadata.go,state.go,errors.go}`
and their tests as the pattern; `cmd/main.go`; `testdata/err_*.json`.
Create `internal/provider/config_proxmox.go`: `ProxmoxConfig{URL,
TokenFile, CAFile string; InsecureSkipTLSVerify bool; Nodes []string;
ISOStorage, Storage, Bridge string; VMIDRange *ProxmoxVMIDRange; Templates
map[string]ProxmoxTemplate; MachineTypes map[string]ProxmoxMachineType;
RequestTimeoutSeconds int; VendorData string; InstallGuestAgent *bool}`,
`ProxmoxVMIDRange{Min, Max int}`, `ProxmoxTemplate{VMID int}`,
`ProxmoxMachineType{Cores int32; MemoryMiB int64; DiskGiB int64}`,
`validateProxmox(prefix string, c *ProxmoxConfig) error` (URL http(s)
with a host; `tokenFile` non-empty; at least one node, unique, matching
`proxmoxNodeName = ^[a-z0-9][a-z0-9-]*$`; `isoStorage` and `storage`
matching `proxmoxStorageID = ^[A-Za-z][A-Za-z0-9_.-]*$`; `vmidRange` min
at least 100, max at most 999999999, min below max; at least one template
with `vmid` at least 100; at least one machine type with cores and
memoryMiB above zero and diskGiB not negative; `requestTimeoutSeconds`
not negative). Edit `config.go`: `Proxmox *ProxmoxConfig` with tag
`proxmox,omitempty`, `setBlocks`, the allow-list, the dispatch. Edit
`cmd/main.go`: `"proxmox": proxmox.New`. Create in
`internal/provider/proxmox/`: `proxmox.go` (`Provider{name, cfg, api,
now, budgets, nameLocks, tracing}`; until Phase 3 a local `keyedLocks`
copy in `locks.go` that Phase 3 deletes; `New` never dials: builds the
client, `os.Stat`s the token file and warns, warns on
`insecureSkipTLSVerify`; `withDefaults`: `isoStorage` `local`, `vmidRange`
100000 to 199999, `requestTimeoutSeconds` 30, `installGuestAgent` true;
`node(zone)` mirrors kvm2's `host`; the four methods stubbed with
`transientf("not implemented")`), `api.go` (the interface and DTOs;
`vmConfig` is `map[string]string` with scalars rendered as strings by
`decodeConfig`), `id.go`, `mark.go` (keys from `provider.LabelManaged`,
`provider.LabelManagedYes`, `provider.LabelUID` and
`proxy-operator-created-at`; `parseDescription` tolerates extra lines
and CRLF), `state.go`, `errors.go` (`apiError{Status int; Message
string; Errors map[string]string}` whose `Error()` never includes headers
or URLs; `classify` per spec entry 13; matcher helpers keyed on the exact
substrings captured in F2 to F6). Tests: `config_proxmox_test.go`
`TestValidateProxmox_table` (missing url, non-http scheme, missing
tokenFile, no nodes, duplicate node, node with a slash, bad isoStorage
id, vmidRange min below 100, min above max, no templates, template vmid
below 100, no machineTypes, cores zero, negative diskGiB, negative
timeout, valid full, valid minimal) with a `validProxmoxYAML` const;
`config_test.go` rows `proxmox type with kvm block`, `kvm type with
proxmox block`, `proxmox minimal entry loads`. `id_test.go`:
`TestFormatProviderID_roundTrip`, `TestParseProviderID_malformed`,
`TestVMIDFor_deterministicAndInRange`, `TestVMIDFor_probesForwardAndWraps`.
`mark_test.go`: `TestFormatDescription_keys`, `TestParseDescription_table`.
`state_test.go`: `TestMapState_table`, `TestFirstIPv4_filters`.
`errors_test.go`: `TestClassify_table` (every class, every matched
message loaded from `testdata/err_*.json` through `loadFixture(t, name)
*apiError`, `op == "create"` turning NotFound into Transient,
`context.Canceled`, a `*net.OpError`, `errors.New("x")`,
already-classified errors keep their class), `TestAPIError_matchers`,
`TestPermanentfAndTransientf`. `proxmox_test.go`:
`TestNew_doesNotDialAndWarnsOnMissingToken`,
`TestNew_insecureSkipTLSVerifyWarns`, `TestNew_nilBlockDoesNotPanic`,
`TestWithDefaults_table`, `TestNode_table`,
`TestWrapErr_carriesProviderNameOpAndID`.
Verify: `grep -c '"proxmox"' internal/provider/config.go` is 3;
`go build ./... && go vet ./... && go test -race -count=1 ./internal/provider/... ./cmd/... && go test -short ./...`;
the fixture loader fails loudly on a missing file.
Done when: `go test -race ./internal/provider/proxmox/` passes with the
stubs in place and the constructor is registered. Commit: `proxmox:
config block, IDs, marks, state mapping, classify, registration`.
- **Phase 3 (implementer): extract `nocloud` and `keyedlock` from kvm2.**
Read first: `internal/provider/kvm2/{seed.go,seed_test.go,kvm2.go,kvm2_test.go,create.go}`;
contract §1.4, §2.1; spec entries 5 and 19;
`grep -n 'seed.go\|keyedLocks\|buildSeedISO' docs/*.md`. Create
`internal/provider/nocloud/seed.go` exporting `BuildSeedISO(name,
userData, vendorData string, w io.Writer) error` (plain errors, an
`ErrEmptyName` sentinel instead of kvm2's `permanentf`),
`BuildVendorData(configured string, installAgent bool) (string, error)`,
`GuestAgentScript`, `SeedLabel`; move the seed tests to
`nocloud/seed_test.go` under their existing names. Create
`internal/provider/keyedlock/keyedlock.go` exporting `Locks`, `New()
*Locks`, `(*Locks).Acquire(ctx, key) (release func(), err error)`; move
the `TestKeyedLocks_*` tests. Edit kvm2: delete `seed.go`, `seed_test.go`,
the `keyedLocks` type and its tests; `create.go` calls
`nocloud.BuildVendorData` and `nocloud.BuildSeedISO` and wraps their
errors with `permanentf`; `kvm2.go` uses `*keyedlock.Locks`. Edit
`internal/provider/proxmox/proxmox.go` to use `keyedlock.Locks` and delete
`locks.go`. Fix the doc mentions so they name the new packages.
Verify: `go test -race -count=1 ./internal/provider/...`; kvm2's test
count drops by exactly the moved tests and the two new packages carry
them; `grep -rn 'iso9660' internal/provider/kvm2/` is empty;
`go vet -tags=integration ./...` still compiles.
Done when: kvm2 and proxmox import both packages, all tests pass, the docs
name the new packages. Commit: `provider: extract nocloud seed builder
and keyedlock from kvm2`.
- **Phase 4 (implementer): seam, HTTP client, decorator, fake.** Read first:
spec entries 14, 17, 18, 20; contract §4.15, §7, §9;
`internal/tracing/transport.go`; `internal/provider/kvm2/{observe.go,observe_test.go,fake_test.go}`;
`internal/provider/proxmox/{api.go,errors.go}`; `testdata/*.json`. Create
`client.go`: `httpAPI{base *url.URL; http *http.Client; tokenFile
string}`; `newHTTPAPI(cfg provider.ProxmoxConfig, opts ...tracing.Option)
(*httpAPI, error)` builds a transport with `DialContext` timeout 10s,
`TLSHandshakeTimeout` 10s, `ResponseHeaderTimeout` from the request
timeout, `IdleConnTimeout` 90s, `MaxIdleConnsPerHost` 4,
`ForceAttemptHTTP2: false`, an empty `TLSNextProto` map (F18), `RootCAs`
from `caFile` or `InsecureSkipVerify` from the flag, wrapped with
`tracing.RestConfigWrapper(opts...)(transport)`; `http.Client.Timeout` is
the request timeout; `do(ctx, method, path string, query, form
url.Values, out any) error` reads the token file on every call (missing
file `transientf`, a file without `!` and `=` `permanentf`), sets the
header, decodes `{"data": ...}` into `out`, turns any non-2xx into
`*apiError` per F1 with the error body read capped at 64 KiB;
`UploadISO` streams a multipart body through `io.Pipe` with a goroutine
writing `content=iso`, `filename` and the file part. Create `observe.go`:
`observed{inner api; provider string}` writing one V(1) `proxmox api
call` line per seam call with `call`, `node`, `vmid`, `storage`, `upid`,
`duration`, `httpStatus` and `error` on failure, V(2) adding request
params and a response summary; `observe(inner api, providerName string)
api`; `New` wraps `httpAPI` in it. Create `fake_test.go`: `fakeAPI{mu; vms
map[int]*fakeVM; tasks map[string]*fakeTask; isos
map[string]map[string][]byte; hooks; block; calls; log; taskPolls int;
cloneFailsWith, startFailsWith string; agentNotRunning, cloneLeavesLock
bool}`, `fakeVM{node, name, status, qmpstatus, lock string; template
bool; config map[string]string; ifaces []agentInterface}`,
`fakeTask{node string; polls int; exit string; onDone func()}`; `Clone`
creates the VM with `lock=clone` and a task that clears the lock or, per
`cloneFailsWith`, removes the VM (F15); `Start` and `Stop` flip status
through a task; `Destroy` removes the VM through a task; `UploadISO`
records bytes; `DeleteVolume` errors with the F13 message when absent;
`enter(ctx, method)` for blocks and hooks like kvm2; helpers `addVM`,
`addTemplate`, `failWith`, `blockOn`, `callCount`, `callLog`,
`fakeErr(status int, msg string) *apiError`. Tests: `client_test.go` over
`httptest.NewTLSServer`: `TestClient_sendsTokenHeaderFromFileOnEveryRequest`
(rotate the file between two calls), `TestClient_missingTokenFileIsTransient`,
`TestClient_unparsableTokenFileIsPermanent`,
`TestClient_apiErrorFromStatusReasonAndBody` (serve each
`testdata/err_*.json` verbatim), `TestClient_decodesDataEnvelope`,
`TestClient_http1Only`, `TestClient_requestTimeoutIsTransient`,
`TestClient_cancelledContextReturnsPromptly`,
`TestClient_childSpanUnderParent`, `TestClient_noSpanWithoutParent`,
`TestClient_tokenNeverInErrorsOrLogs` (sentinel secret echoed by the
server in a 401 body), `TestClient_caFileTrustsPrivateCA`,
`TestClient_insecureSkipsVerification`, `TestClient_uploadStreamsMultipart`
(the server parses the multipart; the client never `io.ReadAll`s the
reader), `TestClient_destroyQueryFlags`. `observe_test.go`:
`TestObserve_everySeamMethodLogsOneLine` (reflection over `api`),
`TestObserve_detailOnlyAtV2`, `TestObserve_uploadBodyNeverLogged`,
`TestObserve_errorFieldsOnFailure`, `TestObserve_v0Silent`.
Verify: `go test -race -count=2 ./internal/provider/proxmox/`;
`grep -n 'ReadAll' internal/provider/proxmox/client.go` shows only the
bounded error-body read; `grep -n 'context.Background()' internal/provider/proxmox/*.go`
is empty outside tests.
Done when: the client passes its tests against the fixtures and the fake
compiles against the full `api` interface. Commit: `proxmox: thin HTTP
client, seam, log decorator and in-memory fake`.
- **Phase 5 (implementer): Get, ListByTag, task helpers.** Read first: spec
entries 7, 8, 9, 12, 13; contract §2.2, §2.4, §4.7, §4.12;
`internal/provider/kvm2/{get.go,get_test.go,list.go,list_test.go}`; the
resources, config, status, agent and task fixtures. Create `task.go`:
`parseUPID(upid) (node string, err error)` (Permanent when malformed),
`waitTask(ctx, log, api, upid, budget) error` polling `TaskStatus` every
`budgets.taskPoll` (2s; tests shrink it) until `status == "stopped"`,
`exitstatus != "OK"` giving `transientf` with the message, the budget
giving `transientf`, ctx giving Transient; `waitUnlocked(ctx, api, node,
vmid, budget)` polling `VMConfig` until no `lock`. Create `get.go`:
`resolve(ctx, api, vmid) (vmResource, bool, error)` (one resources call,
`Type == "qemu"` and the vmid), the name check, `VMConfig` for the mark,
`lock` Transient, `unknown` Transient, `running` reads the agent
(`isAgentNotRunning` gives no IP, other errors Transient), `mapState`.
Create `list.go` per spec entry 12; `Instance` carries ID, UID, CreatedAt
only. Replace the two stubs. Tests: `task_test.go` `TestParseUPID_table`,
`TestWaitTask_table` (ok, error exitstatus, budget exceeded, ctx
cancelled, poll count), `TestWaitUnlocked_table`. `get_test.go`:
`TestGet_missingVMIsNotFound`, `TestGet_nameMismatchIsNotFound`,
`TestGet_unmarkedIsNotFound`, `TestGet_runningWithoutIPIsProvisioning`,
`TestGet_runningWithIPIsRunning`, `TestGet_agentNotRunningIsProvisioning`,
`TestGet_agentOtherErrorIsTransient`, `TestGet_ipFilterSkipsLoopbackAndLinkLocal`,
`TestGet_stoppedIsStopped`, `TestGet_pausedIsStopped`,
`TestGet_unknownStatusIsTransient`, `TestGet_lockedIsTransientNeverNotFound`,
`TestGet_unknownStateIsStopped`, `TestGet_readsUIDAndCreatedAtFromBackend`,
`TestGet_unmanagedDescriptionHasEmptyUID`, `TestGet_malformedIDIsPermanent`,
`TestGet_resourcesFailureIsTransient`, `TestGet_twoVMsWithOurNameIsPermanent`.
`list_test.go`: `TestListByTag_excludesUnmarkedVMs`,
`TestListByTag_emptyUIDStillListed`, `TestListByTag_candidatesByTagOrNamePrefix`,
`TestListByTag_skipsLXCAndTemplates`, `TestListByTag_resourcesFailureFailsWhole`,
`TestListByTag_unreadableVMSkipped`, `TestListByTag_unreadableVMWithEndedContextFails`,
`TestListByTag_unknownStatusStillAttempted`, `TestListByTag_doesNotReadStatusOrAgent`,
`TestListByTag_idsAcceptedByParse`.
Verify: `go test -race -count=2 ./internal/provider/proxmox/`;
`grep -n 'not implemented' internal/provider/proxmox/*.go` lists only
Create and Delete.
Done when: Get and ListByTag are real and every listed test passes.
Commit: `proxmox: Get, ListByTag and task polling`.
- **Phase 6 (implementer): Create.** Read first: spec entries 2, 3, 4, 5, 6,
10, 19; contract §2.1, §4.2, §4.10, §4.11, §5;
`internal/provider/kvm2/{create.go,create_test.go}`;
`internal/provider/nocloud`; F4, F7, F10, F12, F15, F16, F19, F20 in the
Phase 0 log. Create `seed.go`: `seedISOName(name)` is `<name>-seed.iso`,
`seedVolID(storage, name)` is `<storage>:iso/<name>-seed.iso`,
`uploadSeed(ctx, log, api, node, storage, req, cfg)` builds vendor-data
and the ISO into a `bytes.Buffer` (tens of KiB), deletes an existing
volume tolerating absence, uploads with the buffer's length, waits the
task with `budgets.upload`, logs `cloudInitBytes` only. Create `create.go`
implementing the ordered steps: placement validation (`node`,
`MachineTypes`, `Templates`, all Permanent naming field and value), the
per-name lock, `ClusterResources` once for both the name lookup (adoption
guard, then `waitUnlocked` if locked, then continue from the seed step if
stopped, return if running) and the template's node (absent template is
Permanent); allocation loop `for attempt := 0; attempt < maxProbes (32)`
calling `Clone` with `vmidFor(req.UID, rng, attempt)`, `isAlreadyExists`
moving to the next attempt, exhaustion Transient; `waitTask` with
`budgets.clone`; `uploadSeed`; `SetVMConfig` with `cores`, `memory`,
`agent=1`, `tags=proxy_operator`, `ide2=<volid>,media=cdrom`, and `net0`
rewritten from the cloned config with `bridge=<cfg.Bridge>` only when
configured (MAC kept); `bootDisk(cfg)` and `Resize` when `diskGiB` is
above the current `size=`; `Start` and `waitTask` with `budgets.start`;
`waitRunning` on `VMStatus` with `budgets.running`; return the ID.
`isNotExist` inside Create is Transient (classify with `op == "create"`).
Tests `create_test.go`: `TestCreate_repeatReturnsSameIDAndCreatesNothing`,
`TestCreate_resumesAfterEachPartialStep` (after clone task, after seed
upload, after config, after resize, after start, after running),
`TestCreate_returnsOnlyWhenRunning`, `TestCreate_runningWaitCapIsTransient`,
`TestCreate_rejectsUnknownPlacement`, `TestCreate_zoneOptionalWithOneNode`,
`TestCreate_regionIgnored`, `TestCreate_adoptionWithForeignUIDIsPermanent`,
`TestCreate_adoptsOnlyMarkedVMs`, `TestCreate_duplicateNameIsPermanent`,
`TestCreate_vmidCollisionProbesForward`, `TestCreate_vmidRangeExhaustedIsTransient`,
`TestCreate_waitsForCloneLockOnRetry`, `TestCreate_cloneTaskFailureIsTransient`,
`TestCreate_cloneTaskTimeoutIsTransient`, `TestCreate_markWrittenInCloneCall`,
`TestCreate_tagSetInConfigStep`, `TestCreate_seedAlwaysRebuilt`,
`TestCreate_seedUploadedBeforeConfig`, `TestCreate_configSetsCoresMemoryAgentAndSeed`,
`TestCreate_bridgeOverrideKeepsMAC`, `TestCreate_noBridgeOverrideLeavesNet0`,
`TestCreate_resizeOnlyWhenLarger`, `TestBootDisk_table`,
`TestCreate_startTaskFailureIsTransient`, `TestCreate_templateMissingIsPermanent`,
`TestCreate_notExistInsideCreateIsTransientNotNotFound`,
`TestCreate_cloudInitSentinelNeverLoggedOrInErrors` (V(2) capture, every
error path), `TestCreate_concurrentSameNameSerialised`,
`TestCreate_cancelledMidWaitReturnsPromptly`.
Verify: `grep -n 'CloudInit' internal/provider/proxmox/create.go internal/provider/proxmox/seed.go`
shows only `len(req.CloudInit)` and the pass-through into
`nocloud.BuildSeedISO`; `go test -race -count=2 ./internal/provider/proxmox/`;
`grep -n 'not implemented'` lists only Delete.
Done when: Create is real and every listed test passes. Commit:
`proxmox: Create by cloning a template, seed upload, config, start`.
- **Phase 7 (implementer): Delete, Checker, CRD comment.** Read first: spec
entries 11, 16; contract §2.3, §4.3, §4.13, §8;
`internal/provider/kvm2/{delete.go,delete_test.go,check.go,check_test.go}`;
`internal/provider/check.go`; `api/v1alpha1/providerhealth_types.go`; F13,
F17 in the Phase 0 log. Create `delete.go`: parse the ID (Permanent), the
per-name lock, `resolve`; absent is a logged no-op; name mismatch or
unmarked is a logged no-op; `lock` Transient; `unknown` Transient;
`running` gets `Stop` and `waitTask` (`budgets.stop`); `Destroy` and
`waitTask` (`budgets.destroy`), `isNotExist` from `Destroy` being
success; `DeleteVolume` of the seed on the VM's node tolerating the F13
absent message; nil. Create `check.go`: `var _ provider.Checker =
(*Provider)(nil)`; `reasonTemplateNotFound = "TemplateNotFound"`,
`reasonStorageNotFound = "StorageNotFound"`; `Check` fans out one
goroutine per node in config order (`wg.Go`), each `checkNode` running
`Version`, `NodeStatus`, `StorageStatus` (a 500 "does not exist" is
`StorageNotFound`; a storage whose `content` lacks `iso` is
`StorageNotFound` with a message saying so), `ClusterResources` for the
templates; `checkReason(err)`: ctx ended `Timeout`, 401 `Unauthorized`,
403 `Forbidden`, other `*apiError` `APIError`, network `Unreachable`;
Details `pveVersion`, `kernel`, `isoStorage`, `isoStorageAvailGiB`,
`templates`; `Name` is the node, `Address` the base URL. Edit
`api/v1alpha1/providerhealth_types.go` (`kubernetes, gcp, kvm, kvm2 or
proxmox`) and run `make manifests`. Tests `delete_test.go`:
`TestDelete_malformedIDIsPermanent`, `TestDelete_twiceSucceeds`,
`TestDelete_absentIsNoOp`, `TestDelete_foreignNameIsNoOp`,
`TestDelete_unmarkedIsNoOp`, `TestDelete_runningVMStoppedFirst`,
`TestDelete_stoppedVMSkipsStop`, `TestDelete_lockedIsTransient`,
`TestDelete_unknownStatusIsTransient`, `TestDelete_removesSeedISO`,
`TestDelete_toleratesMissingISO`, `TestDelete_destroyTaskFailureIsTransient`,
`TestDelete_usesPurgeAndDestroyUnreferencedDisks`,
`TestDelete_concurrentSameNameSafe`, `TestDelete_noOpLinesAreSilentAtV0`;
in `get_test.go`: `TestGet_afterDeleteReturnsNotFound`,
`TestGet_duringDeleteStillReturnsInstance` (block the fake's `Destroy`).
`check_test.go`: `TestCheck_oneEntryPerNodeInConfigOrder`,
`TestCheck_nodesCheckedConcurrently`, `TestCheck_expiredContextReturnsPromptly`,
`TestCheck_reasonsTable`, `TestCheck_detailsKeys`, `TestCheck_logsVerdictPerNode`,
`TestCheckReason_table`.
Verify: `go test -race -count=2 ./internal/provider/proxmox/ ./internal/providerhealth/`;
`git diff --stat config/` shows only the CRD description line;
`grep -rn 'not implemented' internal/provider/proxmox/` is empty.
Done when: all four contract methods and `Check` are real. Commit:
`proxmox: Delete with seed cleanup, health Checker, CRD type comment`.
- **Phase 8 (implementer): contract sweep, observability sweep, integration
test, Makefile, testing.md.** Read first: contract §11 and §9; spec
entries 17, 18, 20, 21; `internal/provider/kvm2/{provider_test.go,kvm2_integration_test.go}`;
`internal/provider/tracing_test.go`; `Makefile` integration targets;
`docs/testing.md` kvm2 rows and section. Create `provider_test.go`: the
shared `fixture` (fake, log capture, budgets shrunk), `assertProviderError`,
`TestProvider_cancelledContextReturnsPromptly` (each method blocked on
its first seam call), `TestProvider_everyErrorIsWrappedWithConfiguredName`,
`TestLogging_v0SilentV1OutcomeV2Detail` (V(1) lines `proxmox vm ensured
running`, `proxmox vm fetched`, `proxmox vm deleted`, `proxmox vms
listed`, `proxmox node checked`, every line with `provider`, instance
lines with `vmid` and `name`; V(2) has `cloudInitBytes` and never the
text), `TestTracing_httpSpansUnderOperationSpan` (a real `httpAPI`
against an `httptest` server playing the fixtures for a Get, wrapped in
`provider.WithTracing`, with a span recorder: every HTTP span is a child
of `provider.get` and no attribute carries the token). Create
`proxmox_integration_test.go` (`//go:build integration`):
`loadIntegrationEnv` with the `PROXMOX_TEST_*` variables, `providerConfig()`,
the `integrationLog` pattern from kvm2; `TestProxmox_lifecycle` asserting
in order: `New` returns within 100 ms without a request; `Check` Ready
with `pveVersion`, `isoStorageAvailGiB` and `templates`; `Create` returns
`vms/<vmid>/<name>` with the vmid in range; a second `Create` returns the
same ID and the resources count is unchanged; `Get` reaches Running with
an IPv4 within the budget, UID and CreatedAt as written; `ListByTag`
contains it; a TCP connect to the guest's port is logged only; `Delete`
nil; `Get` NotFound; `Delete` again nil; the ISO storage has no
`<name>-seed.iso` and resources has no VM with the name; an HTTP span is
a child of `provider.create` and no attribute carries the cloud-init
text; `PROXMOX_TEST_KEEP` skips the teardown and prints the `qm destroy`
and ISO delete commands. `TestProxmoxCheck_realHost` creates nothing. Edit
`Makefile`: `test-integration-proxmox` (`go test -race -tags=integration
-count=1 -v -timeout 30m -run TestProxmox ./internal/provider/proxmox/`).
Edit `docs/testing.md`: rows for the package in the suites table and a
"The proxmox integration test" section. Update spec entry 21's tick list
with the final test names.
Verify: `go test -race -count=2 ./internal/provider/proxmox/`;
`go vet -tags=integration ./... && go vet -tags=e2e ./...`;
`make -n test-integration-proxmox`; every §11 item has a test name in
spec entry 21 that exists.
Done when: the package covers every §11 item by name, the integration
test compiles, the target exists. Commit: `proxmox: contract and
observability tests, integration test, testing docs`.
- **Phase 9 (implementer): deployment and documentation.** Read first:
contract §12 items 11 to 13; spec entries 22, 23, 24;
`config/manager/manager.yaml`, `config/manager/providers_config.yaml`,
`config/samples/{providers-config.yaml,proxy_kvm2.yaml,kustomization.yaml}`;
`Makefile` `kvm-ssh-secret`; `docs/kvm2-provider.md` headings;
`docs/providers-existing.md`; `docs/architecture.md` lines mentioning
kvm2; `README.md`; `docs/providers.md`; `CLAUDE.md` Gotchas. Edit
`config/manager/manager.yaml`: an optional Secret mount `proxmox-token` at
`/etc/proxy-operator/proxmox` (key `token`). Edit `Makefile`:
`PROXMOX_TOKEN_FILE ?= $(HOME)/.config/proxmox-token`,
`proxmox-token-secret` creating `proxmox-token` from `--from-file=token=`.
Edit both providers-config files: a commented `proxmox-home` entry (`url:
https://192.168.2.8:8006`, `tokenFile`, `caFile` or
`insecureSkipTLSVerify`, `nodes`, `isoStorage`, `storage`, `bridge`,
`vmidRange`, `templates`, `machineTypes`). Create
`config/samples/proxy_proxmox.yaml` (provider `proxmox-home`, zone the
node, machine type `small`, image the template key, the Squid cloud-init
from `proxy_kvm2.yaml`) and list it in `config/samples/kustomization.yaml`.
Create `docs/proxmox-provider.md` with sections: Proxmox prerequisites
(the template commands from this plan, no cloudinit drive on ide2, the
guest agent or outbound access, the bridge, the privilege list from F17,
reserving the VMID range), Cluster-side setup (the Secret, the config
entry), Config reference (spec entry 15's table), Placement (Region
ignored, catalogue-edit warning), What lands on Proxmox (VMID scheme,
description mark, tag, seed ISO, config keys, `qm unlock` for a stuck
clone), Logging, Tracing, Timeouts, Health check, Error classes, Known
gaps (spec entry 24), Testing. Edit `docs/providers-existing.md`: a fifth
column in the three tables and a proxmox section mirroring kvm2's. Edit
`docs/architecture.md`: components row, a proxmox block in §5, the span
note in §10, a §11 row (and the "All four providers" count), Decisions
bullets. Edit `README.md`, `docs/providers.md`, `CLAUDE.md` Gotchas
(`make test-integration-proxmox`).
Verify: `bin/kustomize build config/default >/dev/null && bin/kustomize build config/samples >/dev/null`;
a test `TestLoadConfig_proxmoxSampleEntry` loads the uncommented block
from `config/samples/providers-config.yaml` through `provider.LoadConfig`;
a link sweep over the edited docs finds no missing target;
`grep -c '—' docs/proxmox-provider.md` is 0.
Done when: manifests build, the sample loads, docs are linked and
link-clean. Commit: `proxmox: deployment files, sample, user guide and
doc rows`.
- **Phase 10 (orchestrator with the user): review, real-host validation,
changelog, MR.** Read first: `docs/plans-executions/2026-09-17-1515-kvm2-review-fixes.md`;
`.claude/agents/go-operator-reviewer.md`; the latest report under
`docs/reviews/` for the shape; the spec; this plan's Risks. Do: run
`go-operator-reviewer` on the branch with the spec and the contract as
inputs, then `/code-review high`, `make lint` (if the local toolchain
panics in staticcheck as it did during kvm2, record it and run
`bin/golangci-lint run ./internal/provider/proxmox/...` with staticcheck
disabled), `make test`. Fix accepted findings with regression tests in
one commit; record rejected ones under `docs/reviews/<date>-proxmox-provider-review/`.
Hand the user the host checklist (template ready, no leftover VMs in the
range, ISO storage has space, the token's privileges per F17) and the
`PROXMOX_TEST_*` export lines; with the go-ahead run
`make test-integration-proxmox` and record the clone duration and time to
IP. Optionally the in-cluster pass on kind: build and load the image,
`make proxmox-token-secret`, a scratch ConfigMap with the `proxmox-home`
entry, apply `proxy_proxmox.yaml`, wait for `Ready`, `kubectl get
providerhealth proxmox-home -o yaml`, a `curl -x` through the proxy,
delete and confirm the VM and ISO are gone; scratch overrides are not
committed. After the user confirms: CHANGELOG entry
(`date "+%Y-%m-%d %H:%M %Z"`), push with `-u` using the token helper from
memory (`~/.config/gitea-token`), open the MR with the Gitea API
(`POST /api/v1/repos/kacerr/egress-proxies-operator/pulls` with `head:
feat/proxmox-provider`, `base: main`, the body ending with the
attribution line; `pr.json` in the scratchpad, never committed). Never
merge.
Verify: the integration run's ordered assertions pass on 192.168.2.8;
`make test` green; the review report lists every finding as accepted or
rejected.
Done when: the MR URL is printed and the execution log's last box is
ticked. Commit: `proxmox: review fixes, real-host validation record,
changelog`.
## Verification
- Per phase: the `Verify:` clause, plus `go build ./... && go test -short ./...`
before every commit and `git diff --stat` reviewed for files outside the
phase's list.
- Contract mapping: after Phase 8 every rule of contract §2 and §8 has a
named test in the package, and spec entry 21 maps the §11 list 1:1 to
existing test names.
- Fixture discipline: every message `classify` or a matcher helper compares
against is loaded from `testdata/` in `TestClassify_table` or
`TestAPIError_matchers`; a string literal in `errors.go` without a fixture
is a review finding.
- Secrets: `TestClient_tokenNeverInErrorsOrLogs` and
`TestCreate_cloudInitSentinelNeverLoggedOrInErrors` run at V(2), and the
integration test asserts no span attribute carries the cloud-init text.
- End to end: the integration run (Phase 8's assertions) on 192.168.2.8 and
the optional kind pass in Phase 10.
## Risks
- **Error text location and exact wording (F1 to F6)** decide the whole
NotFound path; if messages vary by storage type or PVE minor version, the
matchers get one fixture per variant and the guide names the tested
version. This is not hypothetical: 9.2 rewrote the authentication-failure
message (F22), so fixtures captured from an 8.x host would already be
wrong. Every matched string comes from 192.168.2.8 at 9.2.20, and the
guide says so.
- **Clone cleanup (F15).** A failed or interrupted clone may leave a VM with
`lock=clone` that never clears; Create then waits out `budgets.clone` and
returns Transient every time. The guide documents `qm unlock`; the
reconciler keeps retrying rather than latching.
- **`SDN.Use` (F17).** PVE 9 refuses `net0` writes to non-root users without
it; a missing privilege is a 403 Permanent that latches the Proxy until
its spec changes. The guide's privilege list is the mitigation.
- **The ide2 seed and a template with a cloudinit drive** both claim
cloud-init; the Proxy's user-data would be ignored. Documented
prerequisite; the Checker cannot see it.
- **VMID range shared with humans.** A human VM inside the range costs a
probe; a human VM carrying our name is Permanent. The guide says to
reserve the range.
- **Node-local ISO storage after a migration** leaves the seed on the
original node; Delete tolerates its absence and the ISO is a documented
blind spot. Shared ISO storage avoids it.
- **Blocking Create** holds a reconcile worker for up to the clone budget;
the guide sets expectations.
- **The 30s request timeout on the seed upload** is fine for tens of KiB; a
multi-megabyte `vendorData` would trip it. Documented.
- **Two operator installs** on one Proxmox cluster delete each other's VMs
(contract §13); documented in entry 24.
- **kvm2 edits in Phase 3** touch a merged provider; the moved tests are the
guard, and `go vet -tags=integration` keeps its integration test compiling.
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"result\":[{\"statistics\":{\"tx-errs\":0,\"rx-dropped\":0,\"rx-bytes\":7101,\"rx-packets\":86,\"rx-errs\":0,\"tx-dropped\":0,\"tx-packets\":86,\"tx-bytes\":7101},\"name\":\"lo\",\"ip-addresses\":[{\"ip-address\":\"127.0.0.1\",\"ip-address-type\":\"ipv4\",\"prefix\":8},{\"ip-address\":\"::1\",\"ip-address-type\":\"ipv6\",\"prefix\":128}],\"hardware-address\":\"00:00:00:00:00:00\"},{\"hardware-address\":\"bc:24:11:48:11:46\",\"ip-addresses\":[{\"ip-address\":\"192.168.0.150\",\"ip-address-type\":\"ipv4\",\"prefix\":24},{\"ip-address\":\"fe80::be24:11ff:fe48:1146\",\"ip-address-type\":\"ipv6\",\"prefix\":64}],\"name\":\"ens18\",\"statistics\":{\"rx-dropped\":0,\"rx-bytes\":29688403,\"rx-packets\":6744,\"tx-errs\":0,\"tx-bytes\":221044,\"tx-dropped\":0,\"tx-packets\":2970,\"rx-errs\":0}}]}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"result\":[{\"ip-addresses\":[{\"prefix\":8,\"ip-address-type\":\"ipv4\",\"ip-address\":\"127.0.0.1\"},{\"prefix\":128,\"ip-address-type\":\"ipv6\",\"ip-address\":\"::1\"}],\"hardware-address\":\"00:00:00:00:00:00\",\"statistics\":{\"tx-errs\":0,\"rx-packets\":86,\"rx-bytes\":7101,\"rx-dropped\":0,\"rx-errs\":0,\"tx-dropped\":0,\"tx-packets\":86,\"tx-bytes\":7101},\"name\":\"lo\"},{\"ip-addresses\":[{\"ip-address-type\":\"ipv4\",\"ip-address\":\"192.168.4.147\",\"prefix\":24},{\"ip-address\":\"fe80::be24:11ff:fea9:b4eb\",\"ip-address-type\":\"ipv6\",\"prefix\":64}],\"hardware-address\":\"bc:24:11:a9:b4:eb\",\"statistics\":{\"rx-errs\":0,\"tx-bytes\":319460,\"tx-dropped\":0,\"tx-packets\":4544,\"tx-errs\":0,\"rx-bytes\":29576554,\"rx-dropped\":0,\"rx-packets\":6027},\"name\":\"ens18\"}]}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":\"UPID:pv1-1:0010F9DC:02801DA1:6AB37871:qmclone:9001:proxy-operator@pve!operator:\"}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":[{\"maxmem\":2147483648,\"netin\":0,\"uptime\":0,\"disk\":0,\"netout\":0,\"mem\":0,\"maxdisk\":34359738368,\"maxcpu\":2,\"type\":\"qemu\",\"memhost\":0,\"template\":0,\"id\":\"qemu/100\",\"cpu\":0,\"name\":\"ubuntu-vm-1\",\"vmid\":100,\"status\":\"stopped\",\"diskwrite\":0,\"diskread\":0,\"node\":\"pv1-1\"},{\"node\":\"pv1-1\",\"diskread\":0,\"diskwrite\":0,\"status\":\"stopped\",\"vmid\":9000,\"cpu\":0,\"name\":\"ubuntu-2604-template\",\"template\":1,\"id\":\"qemu/9000\",\"memhost\":0,\"type\":\"qemu\",\"maxcpu\":2,\"maxdisk\":34359738368,\"mem\":0,\"disk\":0,\"netout\":0,\"uptime\":0,\"netin\":0,\"maxmem\":2147483648},{\"uptime\":0,\"netout\":0,\"disk\":0,\"netin\":0,\"maxmem\":2147483648,\"maxdisk\":3221225472,\"mem\":0,\"id\":\"qemu/9001\",\"template\":1,\"name\":\"debian-13-template\",\"cpu\":0,\"type\":\"qemu\",\"maxcpu\":2,\"memhost\":0,\"diskwrite\":0,\"diskread\":0,\"node\":\"pv1-1\",\"vmid\":9001,\"status\":\"stopped\"}]}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":[{\"template\":0,\"id\":\"qemu/100\",\"cpu\":0,\"name\":\"ubuntu-vm-1\",\"type\":\"qemu\",\"maxcpu\":2,\"memhost\":0,\"diskread\":0,\"diskwrite\":0,\"node\":\"pv1-1\",\"vmid\":100,\"status\":\"stopped\",\"uptime\":0,\"disk\":0,\"netout\":0,\"netin\":0,\"maxmem\":2147483648,\"maxdisk\":34359738368,\"mem\":0},{\"type\":\"qemu\",\"vmid\":199999,\"lock\":\"clone\",\"status\":\"unknown\",\"id\":\"qemu/199999\",\"node\":\"pv1-1\"},{\"diskwrite\":0,\"diskread\":0,\"node\":\"pv1-1\",\"vmid\":9000,\"status\":\"stopped\",\"id\":\"qemu/9000\",\"template\":1,\"name\":\"ubuntu-2604-template\",\"cpu\":0,\"maxcpu\":2,\"type\":\"qemu\",\"memhost\":0,\"maxdisk\":34359738368,\"mem\":0,\"uptime\":0,\"netout\":0,\"disk\":0,\"netin\":0,\"maxmem\":2147483648},{\"type\":\"qemu\",\"maxcpu\":2,\"memhost\":0,\"template\":1,\"id\":\"qemu/9001\",\"cpu\":0,\"name\":\"debian-13-template\",\"vmid\":9001,\"status\":\"stopped\",\"diskwrite\":0,\"diskread\":0,\"node\":\"pv1-1\",\"netin\":0,\"maxmem\":2147483648,\"uptime\":0,\"disk\":0,\"netout\":0,\"mem\":0,\"maxdisk\":3221225472}]}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":[{\"id\":\"qemu/100\",\"template\":0,\"name\":\"ubuntu-vm-1\",\"cpu\":0,\"type\":\"qemu\",\"maxcpu\":2,\"memhost\":0,\"diskwrite\":0,\"diskread\":0,\"node\":\"pv1-1\",\"vmid\":100,\"status\":\"stopped\",\"uptime\":0,\"netout\":0,\"disk\":0,\"maxmem\":2147483648,\"netin\":0,\"maxdisk\":34359738368,\"mem\":0},{\"tags\":\"proxy_operator\",\"mem\":0,\"maxdisk\":4294967296,\"netin\":0,\"maxmem\":1073741824,\"uptime\":0,\"netout\":0,\"disk\":0,\"vmid\":199999,\"status\":\"stopped\",\"diskread\":0,\"diskwrite\":0,\"node\":\"pv1-1\",\"maxcpu\":1,\"type\":\"qemu\",\"memhost\":0,\"id\":\"qemu/199999\",\"template\":0,\"name\":\"proxy-reconaaaaaaaaaaa\",\"cpu\":0},{\"uptime\":0,\"netout\":0,\"disk\":0,\"netin\":0,\"maxmem\":2147483648,\"maxdisk\":34359738368,\"mem\":0,\"id\":\"qemu/9000\",\"template\":1,\"name\":\"ubuntu-2604-template\",\"cpu\":0,\"maxcpu\":2,\"type\":\"qemu\",\"memhost\":0,\"diskwrite\":0,\"diskread\":0,\"node\":\"pv1-1\",\"vmid\":9000,\"status\":\"stopped\"},{\"mem\":0,\"maxdisk\":3221225472,\"maxmem\":2147483648,\"netin\":0,\"uptime\":0,\"disk\":0,\"netout\":0,\"vmid\":9001,\"status\":\"stopped\",\"diskwrite\":0,\"diskread\":0,\"node\":\"pv1-1\",\"maxcpu\":2,\"type\":\"qemu\",\"memhost\":0,\"template\":1,\"id\":\"qemu/9001\",\"cpu\":0,\"name\":\"debian-13-template\"}]}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"meta\":\"creation-qemu=11.0.3,ctime=1790145676\",\"description\":\"proxy-operator-managed=true\\nproxy-operator-uid=00000000-0000-0000-0000-00000000recon\\nproxy-operator-created-at=2026-09-23T07:00:00Z\",\"ide2\":\"local:iso/proxy-reconaaaaaaaaaaa-seed.iso,media=cdrom,size=44K\",\"cores\":1,\"memory\":\"1024\",\"scsihw\":\"virtio-scsi-pci\",\"net0\":\"virtio=BC:24:11:48:11:46,bridge=vmbr0\",\"efidisk0\":\"local-lvm:vm-199999-disk-0,efitype=4m,pre-enrolled-keys=0,size=4M\",\"scsi0\":\"local-lvm:vm-199999-disk-1,size=4G\",\"boot\":\"order=scsi0;ide2\",\"vmgenid\":\"2dd339a4-d7fb-4d47-96fd-5a61a25f808d\",\"digest\":\"98a4fff988b5508a7e1f6aa77c34768812d2382c\",\"name\":\"proxy-reconaaaaaaaaaaa\",\"vga\":\"serial0\",\"serial0\":\"socket\",\"smbios1\":\"uuid=f2ade202-ffdb-4a89-9867-3d419384471d\",\"machine\":\"q35\",\"bios\":\"ovmf\",\"tags\":\"proxy_operator\",\"agent\":\"1\",\"ostype\":\"l26\"}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":\"UPID:pv1-1:00111FF7:02853E62:6AB38591:imgdel:proxy-seeds:proxy-operator@pve!operator:\"}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":\"UPID:pv1-1:00111FF3:02853D30:6AB3858E:imgdel:proxy-seeds:proxy-operator@pve!operator:\"}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":\"UPID:pv1-1:00110564:0281A657:6AB37C5E:qmdestroy:199999:proxy-operator@pve!operator:\"}"
}
@@ -0,0 +1,5 @@
{
"status": 500,
"reason": "QEMU guest agent is not running",
"body": "{\"message\":\"QEMU guest agent is not running\\n\",\"data\":null}"
}
@@ -0,0 +1,5 @@
{
"status": 500,
"reason": "No QEMU guest agent configured",
"body": "{\"message\":\"No QEMU guest agent configured\\n\",\"data\":null}"
}
@@ -0,0 +1,5 @@
{
"status": 500,
"reason": "VM 199999 is not running",
"body": "{\"message\":\"VM 199999 is not running\\n\",\"data\":null}"
}
+5
View File
@@ -0,0 +1,5 @@
{
"status": 400,
"reason": "Parameter verification failed.",
"body": "{\"data\":null,\"message\":\"Parameter verification failed.\\n\",\"errors\":{\"newid\":\"type check ('integer') failed - got 'abc'\"}}"
}
@@ -0,0 +1,5 @@
{
"status": 400,
"reason": "Parameter verification failed.",
"body": "{\"errors\":{\"storage\":\"storage 'local' does not support vm images\"},\"message\":\"Parameter verification failed.\\n\",\"data\":null}"
}
@@ -0,0 +1,5 @@
{
"status": 500,
"reason": "VM is locked (clone)",
"body": "{\"data\":null,\"message\":\"VM is locked (clone)\\n\"}"
}
@@ -0,0 +1,5 @@
{
"status": 500,
"reason": "Configuration file 'nodes/pv1-1/qemu-server/199999.conf' does not exist",
"body": "{\"data\":null,\"message\":\"Configuration file 'nodes/pv1-1/qemu-server/199999.conf' does not exist\\n\"}"
}
+5
View File
@@ -0,0 +1,5 @@
{
"status": 403,
"reason": "Permission check failed (/nodes/pv1-1, Sys.Syslog)",
"body": "{\"message\":\"Permission check failed (/nodes/pv1-1, Sys.Syslog)\\n\",\"data\":null}"
}
@@ -0,0 +1,5 @@
{
"status": 403,
"reason": "Permission check failed (/storage/local, Datastore.Allocate)",
"body": "{\"data\":null,\"message\":\"Permission check failed (/storage/local, Datastore.Allocate)\\n\"}"
}
@@ -0,0 +1,5 @@
{
"status": 500,
"reason": "unable to create VM 199999: config file already exists",
"body": "{\"data\":null,\"message\":\"unable to create VM 199999: config file already exists\\n\"}"
}
@@ -0,0 +1,5 @@
{
"status": 401,
"reason": "No ticket",
"body": ""
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":null}"
}
@@ -0,0 +1,5 @@
{
"status": 400,
"reason": "Parameter verification failed.",
"body": "{\"errors\":{\"tags\":\"invalid format - invalid characters in tag\\n\"},\"data\":null,\"message\":\"Parameter verification failed.\\n\"}"
}
@@ -0,0 +1,5 @@
{
"status": 401,
"reason": "Authentication failed!",
"body": ""
}
@@ -0,0 +1,5 @@
{
"status": 401,
"reason": "Authentication failed!",
"body": ""
}
@@ -0,0 +1,5 @@
{
"status": 400,
"reason": "Parameter verification failed.",
"body": "{\"message\":\"Parameter verification failed.\\n\",\"data\":null,\"errors\":{\"filename\":\"wrong file extension\"}}"
}
@@ -0,0 +1,5 @@
{
"status": 500,
"reason": "Configuration file 'nodes/pv1-1/qemu-server/999999.conf' does not exist",
"body": "{\"message\":\"Configuration file 'nodes/pv1-1/qemu-server/999999.conf' does not exist\\n\",\"data\":null}"
}
@@ -0,0 +1,5 @@
{
"status": 500,
"reason": "Configuration file 'nodes/pv1-1/qemu-server/999999.conf' does not exist",
"body": "{\"data\":null,\"message\":\"Configuration file 'nodes/pv1-1/qemu-server/999999.conf' does not exist\\n\"}"
}
+5
View File
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"rootfs\":{\"total\":100861726720,\"avail\":88717860864,\"used\":6973128704,\"free\":93888598016},\"pveversion\":\"pve-manager/9.2.20/49318c671b82f31e\",\"current-kernel\":{\"machine\":\"x86_64\",\"sysname\":\"Linux\",\"release\":\"7.0.14-17-pve\",\"version\":\"#1 SMP PREEMPT_DYNAMIC PMX 7.0.14-17 (2026-09-10T10:16Z)\"},\"cpu\":0,\"cpuinfo\":{\"mhz\":\"1100.947\",\"user_hz\":100,\"cpus\":16,\"sockets\":1,\"model\":\"AMD Ryzen 9 PRO 8945HS w/ Radeon 780M Graphics\",\"vendor\":\"AuthenticAMD\",\"family\":\"25\",\"hvm\":\"1\",\"flags\":\"fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp lm constant_tsc rep_good amd_lbr_v2 nopl xtopology nonstop_tsc cpuid extd_apicid aperfmperf rapl pni pclmulqdq monitor ssse3 fma cx16 sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand lahf_lm cmp_legacy svm extapic cr8_legacy abm sse4a misalignsse 3dnowprefetch osvw ibs skinit wdt tce topoext perfctr_core perfctr_nb bpext perfctr_llc mwaitx cpuid_fault cpb cat_l3 cdp_l3 hw_pstate ssbd mba perfmon_v2 ibrs ibpb stibp ibrs_enhanced vmmcall fsgsbase bmi1 avx2 smep bmi2 erms invpcid cqm rdt_a avx512f avx512dq rdseed adx smap avx512ifma clflushopt clwb avx512cd sha_ni avx512bw avx512vl xsaveopt xsavec xgetbv1 xsaves cqm_llc cqm_occup_llc cqm_mbm_total cqm_mbm_local user_shstk avx512_bf16 clzero irperf xsaveerptr rdpru wbnoinvd cppc arat npt lbrv svm_lock nrip_save tsc_scale vmcb_clean flushbyasid decodeassists pausefilter pfthreshold vgif x2avic v_spec_ctrl vnmi avx512vbmi umip pku ospke avx512_vbmi2 gfni vaes vpclmulqdq avx512_vnni avx512_bitalg avx512_vpopcntdq rdpid overflow_recov succor smca fsrm flush_l1d amd_lbr_pmc_freeze\",\"cores\":8},\"kversion\":\"Linux 7.0.14-17-pve #1 SMP PREEMPT_DYNAMIC PMX 7.0.14-17 (2026-09-10T10:16Z)\",\"wait\":0,\"boot-info\":{\"mode\":\"efi\",\"secureboot\":1},\"loadavg\":[\"0.05\",\"0.12\",\"0.10\"],\"memory\":{\"used\":2054795264,\"available\":29252005888,\"total\":31306801152,\"free\":28277043200},\"idle\":0,\"uptime\":419233,\"swap\":{\"used\":0,\"total\":8589930496,\"free\":8589930496},\"ksm\":{\"shared\":0}}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":\"UPID:pv1-1:0010FB74:02804090:6AB378CA:resize:199999:proxy-operator@pve!operator:\"}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":\"UPID:pv1-1:0010FB6F:02804085:6AB378CA:resize:199999:proxy-operator@pve!operator:\"}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":\"UPID:pv1-1:0010FB72:0280408B:6AB378CA:resize:199999:proxy-operator@pve!operator:\"}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":null}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":null}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":\"UPID:pv1-1:0010FBF1:02804D0D:6AB378EA:qmstart:199999:proxy-operator@pve!operator:\"}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":\"UPID:pv1-1:0010F9F0:02801DAF:6AB37871:qmstart:199999:proxy-operator@pve!operator:\"}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"serial\":1,\"proxmox-support\":{\"pbs-dirty-bitmap-migration\":true,\"query-bitmap-info\":true,\"backup-fleecing\":true,\"pbs-masterkey\":true,\"pbs-dirty-bitmap-savevm\":true,\"backup-max-workers\":true,\"backup-access-api\":true,\"pbs-dirty-bitmap\":true,\"pbs-library-version\":\"2.0.3 (UNKNOWN)\"},\"maxdisk\":4294967296,\"vmid\":199999,\"ha\":{\"managed\":0},\"diskwrite\":373281280,\"uptime\":858,\"ballooninfo\":{\"mem_swapped_out\":0,\"mem_swapped_in\":0,\"max_mem\":1073741824,\"minor_page_faults\":266583,\"actual\":1073741824,\"last_update\":1790147647,\"major_page_faults\":599,\"free_mem\":414076928,\"total_mem\":943988736},\"running-qemu\":\"11.0.3\",\"disk\":0,\"cpus\":1,\"name\":\"proxy-reconaaaaaaaaaaa\",\"blockstat\":{\"ide2\":{\"rd_merged\":0,\"wr_total_time_ns\":0,\"failed_flush_operations\":0,\"account_failed\":true,\"account_invalid\":true,\"failed_wr_operations\":0,\"unmap_bytes\":0,\"invalid_rd_operations\":0,\"wr_highest_offset\":0,\"failed_zone_append_operations\":0,\"wr_operations\":0,\"unmap_merged\":0,\"wr_bytes\":0,\"idle_time_ns\":847849524087,\"unmap_total_time_ns\":0,\"zone_append_total_time_ns\":0,\"wr_merged\":0,\"rd_total_time_ns\":1255768,\"zone_append_bytes\":0,\"zone_append_operations\":0,\"invalid_unmap_operations\":0,\"unmap_operations\":0,\"invalid_zone_append_operations\":0,\"invalid_wr_operations\":0,\"flush_operations\":0,\"rd_operations\":61,\"invalid_flush_operations\":0,\"zone_append_merged\":0,\"failed_unmap_operations\":0,\"failed_rd_operations\":0,\"flush_total_time_ns\":0,\"rd_bytes\":174304,\"timed_stats\":[]},\"efidisk0\":{\"wr_highest_offset\":15360,\"unmap_merged\":0,\"wr_operations\":0,\"failed_zone_append_operations\":0,\"wr_bytes\":0,\"rd_merged\":0,\"failed_flush_operations\":0,\"wr_total_time_ns\":0,\"account_invalid\":true,\"account_failed\":true,\"invalid_rd_operations\":0,\"unmap_bytes\":0,\"failed_wr_operations\":0,\"invalid_wr_operations\":0,\"invalid_zone_append_operations\":0,\"flush_operations\":0,\"invalid_flush_operations\":0,\"rd_operations\":0,\"failed_rd_operations\":0,\"zone_append_merged\":0,\"failed_unmap_operations\":0,\"timed_stats\":[],\"rd_bytes\":0,\"flush_total_time_ns\":0,\"wr_merged\":0,\"zone_append_total_time_ns\":0,\"unmap_total_time_ns\":0,\"rd_total_time_ns\":0,\"zone_append_operations\":0,\"zone_append_bytes\":0,\"unmap_operations\":0,\"invalid_unmap_operations\":0},\"scsi0\":{\"unmap_operations\":81,\"invalid_unmap_operations\":0,\"zone_append_bytes\":0,\"zone_append_operations\":0,\"rd_total_time_ns\":899575874,\"wr_merged\":0,\"zone_append_total_time_ns\":0,\"idle_time_ns\":18104802903,\"unmap_total_time_ns\":326592,\"timed_stats\":[],\"rd_bytes\":196229120,\"flush_total_time_ns\":47510703,\"failed_rd_operations\":0,\"failed_unmap_operations\":0,\"zone_append_merged\":0,\"invalid_flush_operations\":0,\"rd_operations\":8843,\"invalid_wr_operations\":0,\"flush_operations\":145,\"invalid_zone_append_operations\":0,\"invalid_rd_operations\":0,\"unmap_bytes\":100188160,\"failed_wr_operations\":0,\"account_invalid\":true,\"account_failed\":true,\"failed_flush_operations\":0,\"wr_total_time_ns\":4726294270,\"rd_merged\":0,\"wr_bytes\":373281280,\"unmap_merged\":0,\"wr_operations\":1449,\"failed_zone_append_operations\":0,\"wr_highest_offset\":4294967296},\"pflash0\":{\"unmap_operations\":0,\"invalid_unmap_operations\":0,\"zone_append_operations\":0,\"zone_append_bytes\":0,\"rd_total_time_ns\":0,\"wr_merged\":0,\"zone_append_total_time_ns\":0,\"unmap_total_time_ns\":0,\"timed_stats\":[],\"rd_bytes\":0,\"flush_total_time_ns\":0,\"failed_rd_operations\":0,\"failed_unmap_operations\":0,\"zone_append_merged\":0,\"invalid_flush_operations\":0,\"rd_operations\":0,\"flush_operations\":0,\"invalid_wr_operations\":0,\"invalid_zone_append_operations\":0,\"unmap_bytes\":0,\"invalid_rd_operations\":0,\"failed_wr_operations\":0,\"account_invalid\":true,\"account_failed\":true,\"failed_flush_operations\":0,\"wr_total_time_ns\":0,\"rd_merged\":0,\"wr_bytes\":0,\"unmap_merged\":0,\"failed_zone_append_operations\":0,\"wr_operations\":0,\"wr_highest_offset\":0}},\"agent\":1,\"pid\":1113088,\"tags\":\"proxy_operator\",\"pressureiosome\":0,\"diskread\":196403424,\"pressurememoryfull\":0,\"nics\":{\"tap199999i0\":{\"netin\":30673586,\"netout\":240050}},\"qmpstatus\":\"paused\",\"status\":\"running\",\"netin\":30673586,\"balloon\":1073741824,\"netout\":240050,\"cpu\":0,\"clipboard\":null,\"pressurememorysome\":0,\"pressurecpufull\":0,\"running-machine\":\"pc-q35-11.0+pve0\",\"mem\":529911808,\"pressurecpusome\":0,\"freemem\":414076928,\"maxmem\":1073741824,\"pressureiofull\":0,\"memhost\":674607104}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"running-machine\":\"pc-q35-11.0+pve0\",\"mem\":190930944,\"pressurecpufull\":0,\"pressurememorysome\":0,\"pressureiofull\":0,\"memhost\":337240064,\"pressurecpusome\":0,\"maxmem\":2147483648,\"freemem\":1808162816,\"pressurememoryfull\":0,\"nics\":{\"tap199999i0\":{\"netout\":0,\"netin\":255}},\"diskread\":148379648,\"clipboard\":null,\"status\":\"running\",\"qmpstatus\":\"running\",\"netin\":255,\"balloon\":2147483648,\"netout\":0,\"cpu\":0,\"agent\":1,\"pid\":1112650,\"name\":\"proxy-reconaaaaaaaaaaa\",\"blockstat\":{\"scsi0\":{\"zone_append_operations\":0,\"zone_append_bytes\":0,\"invalid_unmap_operations\":0,\"unmap_operations\":6,\"idle_time_ns\":4599584011,\"unmap_total_time_ns\":24404,\"zone_append_total_time_ns\":0,\"wr_merged\":0,\"rd_total_time_ns\":466322772,\"zone_append_merged\":0,\"failed_unmap_operations\":0,\"failed_rd_operations\":0,\"flush_total_time_ns\":4612531,\"rd_bytes\":148379648,\"timed_stats\":[],\"flush_operations\":19,\"invalid_wr_operations\":0,\"invalid_zone_append_operations\":0,\"rd_operations\":4279,\"invalid_flush_operations\":0,\"account_failed\":true,\"account_invalid\":true,\"failed_wr_operations\":0,\"invalid_rd_operations\":0,\"unmap_bytes\":282624,\"rd_merged\":0,\"wr_total_time_ns\":190079426,\"failed_flush_operations\":0,\"wr_bytes\":6373888,\"wr_highest_offset\":1533730816,\"failed_zone_append_operations\":0,\"wr_operations\":220,\"unmap_merged\":0},\"efidisk0\":{\"rd_merged\":0,\"failed_flush_operations\":0,\"wr_total_time_ns\":0,\"account_invalid\":true,\"account_failed\":true,\"unmap_bytes\":0,\"invalid_rd_operations\":0,\"failed_wr_operations\":0,\"wr_highest_offset\":9216,\"unmap_merged\":0,\"failed_zone_append_operations\":0,\"wr_operations\":0,\"wr_bytes\":0,\"wr_merged\":0,\"zone_append_total_time_ns\":0,\"unmap_total_time_ns\":0,\"rd_total_time_ns\":0,\"zone_append_bytes\":0,\"zone_append_operations\":0,\"unmap_operations\":0,\"invalid_unmap_operations\":0,\"invalid_wr_operations\":0,\"invalid_zone_append_operations\":0,\"flush_operations\":0,\"invalid_flush_operations\":0,\"rd_operations\":0,\"failed_rd_operations\":0,\"zone_append_merged\":0,\"failed_unmap_operations\":0,\"timed_stats\":[],\"rd_bytes\":0,\"flush_total_time_ns\":0},\"pflash0\":{\"wr_total_time_ns\":0,\"failed_flush_operations\":0,\"rd_merged\":0,\"failed_wr_operations\":0,\"unmap_bytes\":0,\"invalid_rd_operations\":0,\"account_failed\":true,\"account_invalid\":true,\"wr_operations\":0,\"failed_zone_append_operations\":0,\"unmap_merged\":0,\"wr_highest_offset\":0,\"wr_bytes\":0,\"rd_total_time_ns\":0,\"unmap_total_time_ns\":0,\"zone_append_total_time_ns\":0,\"wr_merged\":0,\"invalid_unmap_operations\":0,\"unmap_operations\":0,\"zone_append_operations\":0,\"zone_append_bytes\":0,\"rd_operations\":0,\"invalid_flush_operations\":0,\"invalid_zone_append_operations\":0,\"invalid_wr_operations\":0,\"flush_operations\":0,\"flush_total_time_ns\":0,\"rd_bytes\":0,\"timed_stats\":[],\"failed_unmap_operations\":0,\"zone_append_merged\":0,\"failed_rd_operations\":0}},\"pressureiosome\":0.01,\"vmid\":199999,\"ha\":{\"managed\":0},\"diskwrite\":6373888,\"serial\":1,\"proxmox-support\":{\"pbs-dirty-bitmap-savevm\":true,\"pbs-masterkey\":true,\"backup-fleecing\":true,\"pbs-dirty-bitmap-migration\":true,\"query-bitmap-info\":true,\"backup-access-api\":true,\"pbs-dirty-bitmap\":true,\"pbs-library-version\":\"2.0.3 (UNKNOWN)\",\"backup-max-workers\":true},\"maxdisk\":3221225472,\"ballooninfo\":{\"actual\":2147483648,\"mem_swapped_out\":0,\"mem_swapped_in\":0,\"max_mem\":2147483648,\"minor_page_faults\":122998,\"major_page_faults\":616,\"free_mem\":1808162816,\"total_mem\":1999093760,\"last_update\":1790146701},\"uptime\":26,\"running-qemu\":\"11.0.3\",\"disk\":0,\"cpus\":2}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"name\":\"ubuntu-vm-1\",\"mem\":0,\"maxmem\":2147483648,\"memhost\":0,\"maxdisk\":34359738368,\"serial\":1,\"ha\":{\"managed\":0},\"vmid\":100,\"cpu\":0,\"cpus\":2,\"netout\":0,\"disk\":0,\"qmpstatus\":\"stopped\",\"status\":\"stopped\",\"netin\":0,\"uptime\":0,\"clipboard\":null}}"
}
+5
View File
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":\"UPID:pv1-1:0010FAC5:02802A52:6AB37891:qmstop:199999:proxy-operator@pve!operator:\"}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":[{\"size\":45056,\"volid\":\"local:iso/proxy-reconaaaaaaaaaaa-seed.iso\",\"format\":\"iso\",\"content\":\"iso\",\"ctime\":1790146742}]}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"total\":100861726720,\"active\":1,\"enabled\":1,\"used\":6973128704,\"avail\":88717860864,\"type\":\"dir\",\"shared\":0,\"content\":\"iso,import,backup,vztmpl\"}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"upid\":\"UPID:pv1-1:0010F9DC:02801DA1:6AB37871:qmclone:9001:proxy-operator@pve!operator:\",\"exitstatus\":\"OK\",\"status\":\"stopped\",\"pid\":1112540,\"pstart\":41950625,\"user\":\"proxy-operator@pve\",\"node\":\"pv1-1\",\"type\":\"qmclone\",\"starttime\":1790146673,\"id\":\"9001\",\"tokenid\":\"operator\"}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"pstart\":41962765,\"upid\":\"UPID:pv1-1:0010FBF1:02804D0D:6AB378EA:qmstart:199999:proxy-operator@pve!operator:\",\"pid\":1113073,\"node\":\"pv1-1\",\"user\":\"proxy-operator@pve\",\"type\":\"qmstart\",\"status\":\"running\",\"tokenid\":\"operator\",\"id\":\"199999\",\"starttime\":1790146794}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"exitstatus\":\"OK\",\"status\":\"stopped\",\"upid\":\"UPID:pv1-1:0010F9F0:02801DAF:6AB37871:qmstart:199999:proxy-operator@pve!operator:\",\"type\":\"qmstart\",\"tokenid\":\"operator\",\"id\":\"199999\",\"starttime\":1790146673,\"pstart\":41950639,\"pid\":1112560,\"node\":\"pv1-1\",\"user\":\"proxy-operator@pve\"}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"type\":\"resize\",\"tokenid\":\"operator\",\"id\":\"199999\",\"starttime\":1790146762,\"pstart\":41959563,\"pid\":1112946,\"user\":\"proxy-operator@pve\",\"node\":\"pv1-1\",\"exitstatus\":\"shrinking disks is not supported\",\"status\":\"stopped\",\"upid\":\"UPID:pv1-1:0010FB72:0280408B:6AB378CA:resize:199999:proxy-operator@pve!operator:\"}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"exitstatus\":\"OK\",\"status\":\"stopped\",\"upid\":\"UPID:pv1-1:0010FAC5:02802A52:6AB37891:qmstop:199999:proxy-operator@pve!operator:\",\"type\":\"qmstop\",\"starttime\":1790146705,\"id\":\"199999\",\"tokenid\":\"operator\",\"pid\":1112773,\"pstart\":41953874,\"user\":\"proxy-operator@pve\",\"node\":\"pv1-1\"}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"user\":\"proxy-operator@pve\",\"node\":\"pv1-1\",\"pstart\":42286690,\"pid\":1122295,\"id\":\"proxy-seeds\",\"tokenid\":\"operator\",\"starttime\":1790150033,\"type\":\"imgdel\",\"upid\":\"UPID:pv1-1:00111FF7:02853E62:6AB38591:imgdel:proxy-seeds:proxy-operator@pve!operator:\",\"status\":\"stopped\",\"exitstatus\":\"OK\"}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":\"UPID:pv1-1:0010FB2D:028038B0:6AB378B6:imgcopy::proxy-operator@pve!operator:\"}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":\"UPID:pv1-1:0010FB27:0280377E:6AB378B3:imgcopy::proxy-operator@pve!operator:\"}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":\"UPID:pv1-1:00110D02:0282B09D:6AB37F08:imgcopy::proxy-operator@pve!operator:\"}"
}
+5
View File
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"version\":\"9.2.20\",\"repoid\":\"49318c671b82f31e\",\"release\":\"9.2\"}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"boot\":\"order=scsi0\",\"vmgenid\":\"2dd339a4-d7fb-4d47-96fd-5a61a25f808d\",\"digest\":\"e747d94a5aac26cf6528b43835600b1bc4e82dda\",\"scsi0\":\"local-lvm:vm-199999-disk-1,size=3G\",\"serial0\":\"socket\",\"name\":\"proxy-reconaaaaaaaaaaa\",\"vga\":\"serial0\",\"cores\":2,\"meta\":\"creation-qemu=11.0.3,ctime=1790145676\",\"description\":\"proxy-operator-managed=true\\nproxy-operator-uid=00000000-0000-0000-0000-00000000recon\\nproxy-operator-created-at=2026-09-23T07:00:00Z\",\"efidisk0\":\"local-lvm:vm-199999-disk-0,efitype=4m,pre-enrolled-keys=0,size=4M\",\"memory\":\"2048\",\"scsihw\":\"virtio-scsi-pci\",\"net0\":\"virtio=BC:24:11:48:11:46,bridge=vmbr0\",\"agent\":\"1\",\"ostype\":\"l26\",\"bios\":\"ovmf\",\"machine\":\"q35\",\"smbios1\":\"uuid=f2ade202-ffdb-4a89-9867-3d419384471d\"}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"ostype\":\"l26\",\"agent\":\"1\",\"smbios1\":\"uuid=5b131064-9841-4bc7-982e-5fb5b2e677eb\",\"machine\":\"q35\",\"bios\":\"ovmf\",\"scsi0\":\"local-lvm:base-9001-disk-2,size=3G\",\"digest\":\"03bdc5d457ce2165bb633505d2ff0cd94340dbc2\",\"vmgenid\":\"9a42b153-7965-4601-864d-59fc2d71ecd4\",\"boot\":\"order=scsi0\",\"vga\":\"serial0\",\"template\":1,\"name\":\"debian-13-template\",\"serial0\":\"socket\",\"meta\":\"creation-qemu=11.0.3,ctime=1790145676\",\"cores\":2,\"net0\":\"virtio=BC:24:11:93:25:82,bridge=vmbr0\",\"scsihw\":\"virtio-scsi-pci\",\"memory\":\"2048\",\"efidisk0\":\"local-lvm:base-9001-disk-0,efitype=4m,pre-enrolled-keys=0,size=4M\"}}"
}
@@ -0,0 +1,5 @@
{
"status": 200,
"reason": "OK",
"body": "{\"data\":{\"ciuser\":\"ubuntu\",\"smbios1\":\"uuid=6ba36ff6-9d1f-457b-8cdc-a44448f5bd35\",\"ostype\":\"l26\",\"memory\":\"2048\",\"scsihw\":\"virtio-scsi-pci\",\"net0\":\"virtio=BC:24:11:2F:97:A0,bridge=vmbr0,tag=4\",\"ide2\":\"local-lvm:vm-9000-cloudinit,media=cdrom\",\"cores\":2,\"meta\":\"creation-qemu=11.0.0,ctime=1789719724\",\"ipconfig0\":\"ip=dhcp\",\"serial0\":\"socket\",\"name\":\"ubuntu-2604-template\",\"template\":1,\"vga\":\"serial0\",\"boot\":\"order=scsi0\",\"vmgenid\":\"37d16d5c-957f-4c83-aab3-b403513e466c\",\"digest\":\"8820e9bb34a576d0f54b94cfe3e69fbfdf13a8ce\",\"scsi0\":\"local-lvm:base-9000-disk-0,size=32G\"}}"
}