0007. e2e runs Talos nodes on a real cluster
Status: Accepted · Date: 2026-10-25 · Design section: 7 (Testing)
Decided: e2e runs Talos nodes on a real cluster, not talosctl cluster create qemu
Context
The design says e2e creates throwaway clusters with talosctl cluster create qemu. That provisioner runs QEMU on the host that invokes it and needs
CAP_SYS_ADMIN and CAP_NET_ADMIN — root. The development workstation has
/dev/kvm world-readable and VT-x enabled, but sudo requires a password that
an agent session cannot supply, and the machine has 4 vCPUs and 15 GB of RAM,
which caps it at a single-node cluster.
A second host was available: a three-node bare-metal Talos cluster
(talos-cluster01, Talos 1.13.1 / Kubernetes 1.36.3) whose first node has 16
cores and 62 GB. A privileged pod there sees real /dev/kvm and /dev/net/tun
on AMD-V hardware — no nesting involved.
The second was tried and it works, with a result better than the first would have given:
- A node booting the Image Factory
metal-amd64.isofor Talos 1.14.3 reachesMachineStatusSTAGE: maintenance, READY: true. - The insecure maintenance API answers on port 50000 from any pod in the
cluster. (Port-forward does work under
masquerade, which is how this was first measured before the binding changed — a detail that cost a debugging cycle downstream.) Tests therefore reach the Talos API from a one-shot pod inside the cluster, which is also how the controller will reach nodes in production. get disksshows the ISO assr0and a writable 11 GBvda, so there is a real install target, andget linksshows a real NIC plus bond and dummy devices.
That is Ichor's entry point reproduced exactly: a node that is up, unconfigured, and readable over an unauthenticated connection.
Decision
CI keeps the talosctl cluster create qemu path: GitHub's ubuntu-24.04
runners provide /dev/kvm and passwordless sudo, and Talos's own
documentation treats them as sufficient. So there are two harnesses, which is
the cost of this decision, and the e2e suite takes the cluster it is given
through KUBECONFIG and a set of node endpoints rather than creating one
itself.
Facts worth keeping, each of which cost a debugging cycle:
talosctl1.14.3 has no global--insecure. It is-i/--insecureon the individual commands, and flags must follow the subcommand.--cert-fingerprintpins the server certificate even in maintenance mode. The design notes that insecure apply does not authenticate the node and assumes a separate provisioning network. Fingerprint pinning is a second defence worth offering once a node's fingerprint is known, and the discovery flow should record it.
Consequences
- e2e gets real disks, a real install target and real maintenance mode, on 16
cores and 62 GB — enough for the
standardtopology that the workstation could never host. - Ichor owns the node manifests that talosctl's provisioner would otherwise have
provided, and does not get
--presets maintenanceor the provisioner's networking for free. masqueradeNATs the node behind its pod IP and forwards only the ports declared on the interface, which would mean enumerating every port Talos nodes use between themselves — apid, trustd, apiserver, etcd, kubelet — plus whatever the inner cluster's CNI encapsulates with.bridgegives the node the pod IP itself, so there is no NAT and no port list: measured from an ordinary pod, with no ports declared, 50000 is reachable while 50001, 22 and 6443 are refused — which is the node's real state in maintenance mode, where only apid is listening, not a filter. node-to-node traffic therefore reduces to pod-to-pod traffic, which Cilium already provides, and Multus is not needed.- The QEMU harness is written but unverified. It creates a cluster with one
worker and resets that worker back into maintenance mode, because
--presets maintenancewould leave no cluster at all and the specs need both a cluster and a maintenance node. The part that cannot be tested here is how it learns the worker's address: it scrapestalosctl cluster create's human-readable summary, a format that is not a documented interface and was never observed, because running the provisioner needs root this environment does not have. It is marked with ashortcut:comment and fails loudly rather than silently when the scrape comes back empty, so the first CI run on a QEMU-capable runner is its test. That is a deliberate trade: splitting the spec set to avoid the risk would have narrowed what CI checks, which costs more than a red build does. - The e2e suite must not assume it created the cluster, which is the right
shape anyway: it is also how
adoptwill be tested later. - Nor may it assume it created the nodes. So the suite consumes a cluster through
KUBECONFIGand a set of maintenance-mode node endpoints through the environment, failing loudly when either is absent, and each harness's setup target supplies them its own way. One set of specs runs on both.
Past maintenance mode, resolved
The ISO path works end to end, and was
observed working: booted from the Factory ISO, a control-plane config applied,
the console showed the install pull the installer image, write to /dev/vda and
log install successful, then reboot; the new boot read its configuration from
the STATE partition and ran the boot sequence rather than reinstalling, with
apid and trustd listening about two seconds in. From there the authenticated API
answered with RBAC, talosctl bootstrap succeeded, and get machinestatus
returned stage: running, ready: true with etcd Running and healthy.
Keep the ISO as the boot source. The Factory also publishes a pre-installed
metal-amd64.raw.xz, and booting that would skip the install entirely — which
sounds attractive until you notice that phase 3's whole flow is install:
discover a node in maintenance mode, approve it, install, join. A harness that
boots pre-installed disks cannot exercise any of it.
Five things the harness has to get right, each of which cost a debugging cycle:
- Use a control-plane config, not a worker. This was the hang. Talos's
certificate controller, for workers only, blocks on
k8s.Endpointbeing non-empty — the address of a real control-plane apiserver — before it will mint apid's serving certificate. An isolated worker never satisfies that, so apid and trustd never start: the kernel is up, containerd and the kubelet are up, and 50000/50001 are refused forever. The control-plane certificate path needs only local secrets, and comes up about two seconds into the second boot. Any single-node Talos-API-reachability fixture must therefore be control-plane shaped; that is a Talos constraint, not something a harness can work around. - Pin the cluster endpoint to the node's own address.
talosctl gen config's placeholder endpoint resolved through the pod gateway to the host cluster's real kube-apiserver, and Talos's endpoint controllers dialled it repeatedly on their own. Every attempt was rejected by TLS verification — the test CA does not match — so nothing was read or written, but a node on this bridge-bound pod network can reach the production control plane's apiserver port, and the harness must never leave that endpoint at a default. - Give the inner cluster non-overlapping subnets. The defaults,
10.244.0.0/16and10.96.0.0/12, collide with the host cluster's pod network, which is where the node's own address comes from. Talos raises anaddress-overlapdiagnostic and warns that it cannot find a suitable node IP. - Console output on the ISO, and only there. A schematic's
extraKernelArgs: [console=ttyS0,115200n8]gives a serial console for the ISO boot, which is what made the original hang understandable after three blind attempts. Keeping it past the install reboot would needmachine.install.extraKernelArgsandgrubUseUKICmdline: falsein the machine config — and on Talos 1.14 that means hand-editing the legacy v1alpha1 section of a config whose install and network settings live in their own documents (UnattendedInstallConfig,KubeNetworkConfig,KubeFlannelCNIConfig), which the API rejects as incompatible. So the harness does not try. The post-install console was a debugging aid from when the cause was unknown; now that it is known, it is a nicety that was blocking the actual goal. Configure the inner cluster through the documents the generator emits rather than against them, and mark the missing post-install console with ashortcut:naming the Talos release that requires the trade. - Detach the ISO after install. It happens to work without doing so, because Talos's own first-boot detection finds the STATE partition and runs the boot sequence rather than reinstalling. That is incidental and not worth relying on.
Re-verified end to end; six more things it took
The whole flow — ISO boot, install, bootstrap, etcd healthy, the e2e spec set
against the inner cluster, teardown — was run for real multiple times on
talos-cluster01 to confirm the above still holds and that cleanup actually
leaves ichor-e2e empty. It does, but getting there cost six more cycles:
talosctl bootstrapcan race its own prerequisite. Immediately after the authenticated API starts answeringmachinestatus(the harness's own signal that install finished), bootstrap can still returnFailedPrecondition: bootstrap is not available yet— apid being reachable doesn't mean etcd's bootstrap RPC is registered yet. Retry it like every other condition the harness polls for, instead of treating it as fatal.MachineStatus.readyis not where it looks. It lives atspec.status.ready, notspec.ready— confirmed against a real node's JSON. Both the harness's running/ready wait and the e2e spec's own assertion had the wrong path, so the check was always false and either hung or always passed trivially depending on how it was written. Worth remembering for any other MachineStatus field:spec.statusis itself an object, not a flattening of its parent.- The inner cluster needs its own
ichor-e2enamespace. The harness only gets the node and the CRDs up; nothing creates a namespace of that name on the inner cluster, and the e2e spec's CEL fixtures assume one exists there. Easy to miss because the name collides with the outer cluster's namespace, which does pre-exist — two different clusters, same name, one of them actually needs creating. nix developowns the nameout. It exportsout=<store path>by convention (every Nix derivation builder gets one), andmake install/uninstallreused that exact name for the kustomize-rendered CRD YAML withoutexport -n. The variable kept the export attribute from the shell's own setup, so every subsequent subprocess in that shell inherited a 170KB+ environment variable, until some laterexec(observed:kubectl) hit the kernel's combined argv+envp limit with "Argument list too long" — nowhere near the command that actually assigned the value. Renaming the local variable is the whole fix; nothing about the CRDs or kustomize was wrong.- Cleanup must check its target as carefully as setup does. Observed for real: a dangling
kind-kindcontext left over from before this project moved off Kind made cleanup fail outright (connection refused) instead of silently targeting the wrong cluster — but "fails outright" was luck, not a guarantee, for a target that could just as easily have been a live cluster with its ownichor-e2enamespace. Both targets now share the same prerequisite check, and the harness binary itself refuses to run at all (upordown) whenKUBECONFIGis unset, rather than fall back to kubectl's ambient context. go runcannot be trusted with a signal-triggered cleanup. The harness traps SIGINT/SIGTERM to rundown()before exiting, so a killedupcan't strand the node. Any harness step whose correctness depends on catching a signal has to run as the actual binary, not undergo run.
Phase 3: no new scenario was added, and why
Design's "Testing" section lists phase-3 features as e2e candidates: the
delete guard's quorum/last-control-plane refusal (ADR 0010), the CNI preset
rules (preset immutability, flannel cannot replace kube-proxy, the
kube-proxy-switch confirmation annotation), spec.discovery's CEL limits, and
the nine secret fields being pruned rather than rejected (ADR 0003). The
roadmap names one of these explicitly: "an explicit test has to show that the
annotation alone does not let the last control plane go."
All four already exist against a real apiserver, so none were duplicated into this harness:
- Deletion guard quorum, including the exact sequential case the roadmap
asks for (three control planes, delete one — allowed, delete another —
refused for quorum, even with
allow-deleteset):internal/webhook/v1alpha1/ talosmachine_webhook_test.go,Describe("TalosMachine Webhook control-plane quorum"), run underwebhook_suite_test.go's envtest environment with the real webhook server and its TLS serving certificate — the actual admission path, not a bare function call. - CNI preset immutability, the kube-proxy-switch
ValidatingAdmissionPolicy, andspec.discovery's CEL limits (IPv4-only,/24ceiling,MaxItems=4CIDRs):api/v1alpha1/validation_test.go, underapi/v1alpha1/ suite_test.go's envtest environment. That suite's own comment says why the VAP is included even though it isn't controller-gen output: it loadsconfig/admission/kubeproxy-switch-policy.yamlverbatim, "so the test exercises the exact manifest a real deploy installs rather than a copy that could drift from it." - The nine secret fields being pruned, not rejected: the same file,
It("drops all nine secret fields from a Config document, however it arrives").
envtest runs a real kube-apiserver and etcd binary — CRD-embedded CEL,
ValidatingAdmissionPolicy and structural-schema pruning are all apiserver
features, identical under envtest's apiserver and this harness's inner one.
None of these four checks involves the controller (the quorum rule is the
only webhook among them, and envtest already stands up the real webhook
server over TLS), so re-running them against a node would exercise the
same apiserver code path a second time, slower and more fragile, for no new
confidence — exactly what "don't duplicate what a unit/envtest suite already
covers" rules out.
Fixed vmName/relayLocalPort: why the collision risk was left as-is
vmName and relayLocalPort are fixed, not generated, because up and
down are separate process invocations with no shared state between them
(see vmName's own doc comment). Two concurrent runs against the same
cluster would collide on both. Left as-is: every caller of this harness —
make test-e2e's short set, and a human running setup/test-e2e/cleanup
by hand — runs one invocation at a time against ichor-e2e; nothing here
runs two. Making the names unique would need a state file handed from up to
down across those separate invocations, for concurrency nobody has asked
this harness to support.
Three control planes: etcd healthy, confirmed; removal ordering, not yet
Separate from up/down: the short e2e set's single-node path is untouched, and three
nodes (~40Gi of Ceph for a few minutes) is nightly-only.
Confirmed for real (talos-cluster01, three nodes, bootstrap on the first, the other
two joining on their own): all three reach running/ready, and talosctl health --control-plane-nodes <all three> passes every check through "all k8s nodes to report
schedulable". talosctl etcd members against any one of them shows all three as
voters, not learners:
NODE ID HOSTNAME PEER URLS CLIENT URLS LEARNER
10.244.1.223 42e7cb66e97d0e6b ichor-e2e-ha-2 https://10.244.1.226:2380 https://10.244.1.226:2379 false
10.244.1.223 c1a928177d9c3bd5 ichor-e2e-ha-0 https://10.244.1.223:2380 https://10.244.1.223:2379 false
10.244.1.223 f80ae7490b84eb49 ichor-e2e-ha-1 https://10.244.1.173:2380 https://10.244.1.173:2379 false
Two things cost a debugging cycle each, both already fixed in the harness:
- A single ISO disk shared read-only across all three nodes does not work on
this cluster's storage. Tried first, to avoid three copies of the identical Factory
image: rook-ceph's RBD CSI driver refused the
ReadOnlyManyPVC outright --rpc error: code = InvalidArgument desc = readOnly accessMode is supported only with content source-- confirmed against a real, standalonedisk. talosctl health's--nodestakes exactly one node, not the list.--control-plane-nodesis the list to check;--nodes(-n) is the single node the check runs from. Passing the same three-node list to both failed outright ("command health requires exactly one node (got 3)") -- confirmed for real, fixed by passingcontrolPlaneNodes[0]to--nodes.
Not attempted this round: the removal-ordering scenarios themselves (quorum/ last-control-plane admission refusal, a single removal completing in order, the transitioning lock, killing the controller mid-removal and resuming). All of them need the real controller reconciling against this inner cluster, which surfaces a boundary "run outside the inner cluster" doesn't resolve on its own:
- The controller's webhook cannot be reached from "the workstation." The inner
apiserver (inside a bridge-bound node on
talos-cluster01's pod network) has to dial the controller's webhook port for everyTalosMachineadmission request. A controller process running on the machine driving this harness is not reachable from that pod network at all -- the same one-way boundary ADR 0007 already states forkubectl port-forward("the workstation running this harness cannot reach the node's pod IP directly"), just in the opposite direction this time: nothing on that pod network can reach back to the workstation either. A kubeconfig pointed at the inner cluster lets the controller's own API calls go out through a tunnel; it does nothing for the inner apiserver's calls back in. - The fix is where the controller runs, not what it's pointed at. A pod inside
ichor-e2eontalos-cluster01sits on the same flat pod network the nodes do -- exactly how the existingtalosctlone-shot pods already reach a node's maintenance API directly by IP, no relay needed. A controller pod there, given the inner cluster's admin kubeconfig (fromtalosctl kubeconfig, already has full access -- no separate RBAC setup needed on either cluster) and with the webhook config'sclientConfig.urlpointed at that pod's own IP, would be reachable both ways without a tunnel in either direction. - This sandbox has no
docker/podman, confirmed (which dockerfinds nothing), so there is no image to run that pod from through the normal path (docker-build/Dockerfile). The workable alternative iskubectl cp-ing a statically builtbin/managerinto a running pod on a minimal base image andexec-ing it there, which also needs the webhookValidatingWebhookConfigurationand certSecretapplied to the inner cluster under the exact namescmd/main.go's cert-controller hardcodes (ichor-validating-webhook-configuration,ichor-webhook-server-cert--config/webhook/'s own files ship prefix-less, relying onconfig/default'snamePrefixto add it, which this path would have to do by hand instead). - Separately, running the real reconciler at all means it starts managing these
already-configured nodes for real --
TalosMachineReconciler.Reconcilerenders and applies a config for every approved control-planeTalosMachineunconditionally, the same step that also adds the removal finalizer. Making theTalosCluster/TalosMachinespecs match whatupHAalready applied closely enough that this is a safe no-op sync rather than a surprise reconfiguration of a live three-node etcd cluster is its own piece of care, not a given.
None of this is a dead end -- the pod-on-the-outer-cluster shape above is a concrete, believable path -- but it is three new pieces of infrastructure (image delivery, webhook TLS/naming, a config that matches reality closely enough) stacked on top of a live, Ceph-backed three-node cluster, each a plausible place to fail twice and have to stop. Given that stack and the time already spent standing the cluster up, it was left for a dedicated pass rather than attempted inline here. Verified and cleaned up instead: the part that needed real hardware and nothing else -- three control planes, real etcd, real quorum.
The controller's webhook, reachable from the inner apiserver: confirmed working
The proposal above (a controller pod inside ichor-e2e, on the same pod network the nodes
use) is no longer untested. Scope on purpose: reachability only, stopping at seeing
admission refuse one delete. Quorum, last-control-plane, removal ordering,
transitioning and chaos-resume are still deferred to a dedicated pass.
Confirmed for real, against a single-node inner cluster: a TalosMachine deleted
without ichor.maesh.dev/allow-delete is refused by the live admission webhook, and the
identical object deletes cleanly once annotated:
$ kubectl delete talosmachine wh-machine
Error from server (Forbidden): admission webhook "vtalosmachine-v1alpha1.kb.io" denied the request: delete refused: set the annotation ichor.maesh.dev/allow-delete="true" to allow it
$ kubectl annotate talosmachine wh-machine ichor.maesh.dev/allow-delete=true
talosmachine.ichor.maesh.dev/wh-machine annotated
$ kubectl delete talosmachine wh-machine
talosmachine.ichor.maesh.dev "wh-machine" deleted from default namespace
That first error travelled the whole real path: the inner apiserver dialled
https://<pod IP>:9443/validate-... over TLS, verified the leaf certificate against the
caBundle in the ValidatingWebhookConfiguration, and the real cmd/main.go binary
(statically built, kubectl cp'd in, no image) ran deleteguard.CheckDelete and
returned the refusal.
How it was built, one piece per trap
- A pod anywhere in
ichor-e2e, not a Service.alpine:3runningsleep infinity,kubectl cp'd the statically-builtbin/managerbinary and the inner cluster's kubeconfig into it, thenkubectl exec'd the binary with that kubeconfig andPOD_NAMESPACE=default. No image build (out of scope). A Service was never an option for the inner apiserver to reach: aClusterIPis only routable within the cluster whose own kube-proxy/Cilium programmed it -- the inner cluster has no idea the outer one's Services exist, flat pod network or not. The pod's own IP, on that same flat network, is the only address that means anything to both sides. - The kubeconfig that reaches the pod is not the one the harness publishes.
publish()rewrites the node's own endpoint to127.0.0.1:<relayLocalPort>for the workstation's port-forward tunnel (ADR 0007's existing boundary). A pod on the outer cluster's own pod network needs the opposite: the node's real address (https://<node pod IP>:6443), which a one-linesedrestores from the published kubeconfig before copying it in. - Leader election's namespace is not
POD_NAMESPACE. The rotator'sSecretKeynamespace comes fromPOD_NAMESPACE(set todefault, a namespace the fresh inner cluster already has);ctrl.Options.LeaderElectionNamespaceis unset incmd/main.goand auto-detects from the pod's own mounted service-account namespace file --ichor-e2e, since that is the outer pod's namespace, not a namespace on the inner cluster at all.leaderelection.go: Error retrieving lease lockagainst the inner apiserver, repeating forever, was the symptom. Fixed by creating anichor-e2enamespace on the inner cluster too -- harmless, the inner cluster is fully disposable, and leader election stops caring once the namespace exists. - The webhook cert
Secretmust already exist, empty. Confirmed by readingconfig/webhook/secret.yaml(ships metadata-only, matchingdist/chart/ chart_test.go's owncheckCertSecretHasNoData):rotator.CertRotatoronly everGets thenUpdates a Secret; it never creates one, so a cluster with no such Secret getsSecret "ichor-webhook-server-cert" not foundforever. Onekubectl create secret generic ichor-webhook-server-certin the inner cluster'sdefaultnamespace fixed it. open-policy-agent/cert-controller'sCertRotatorcannot produce an IP-SAN certificate, and never writes to disk at all. Read the vendored source (pkg/rotator/rotator.go, v0.16.0) to confirm both:generateCert'sx509.Certificatetemplate setsDNSNamesonly, neverIPAddresses--ExtraDNSNamesexists but is still a DNS name, and Go's TLS client verifies an IP dial target againstIPAddresses, notDNSNames, so a cert from this library can never satisfy ahttps://<pod IP>URL no matter how it's configured. And there is noos.WriteFileanywhere in the package --ensureCertsMountedonly pollsCertDirfor files to already exist; the real deployment'smanager.yamlpopulates them by mounting the Secret as a volume, which this one-off pod has no way to do. Fix: readca.crt/ca.keystraight out of the Secretrotatoralready wrote, mint a new leaf for the pod's own IP signed by that same CA key (a ~70-line scratch Go program, not part of the repo), andkubectl cptls.crt/tls.keyinto/tmp/k8s-webhook-server/serving-certs/myself.controller-runtime's owncertwatcherpicked the files up and started serving within its normal poll interval -- no code incmd/main.goneeded to change; this cert never goes through the rotator at all; the rotator just happens to be the Secret's owner. Verified empirically before committing to this: a firstkubectl deleteattempt gotconnection refusedon port 9443 (reachable, nothing listening yet -- a useful intermediate signal that the network path itself was already correct), and the webhook server started serving the moment the hand-placed cert files appeared.
Cleanup, verified including the new pieces
The ~70-line scratch program became real harness code
Promoted the manual leaf-minting procedure above into test/harness/webhookcert
(MintLeafForIP), with tests that verify the leaf against the CA and against the wrong
IP/wrong CA.
Getting this orchestration code to actually run clean against the live 3-control-plane cluster took five real-hardware attempts, each a genuinely different bug:
Secret "ichor-webhook-server-cert" not found-- thecreate secretcall was missing-n/--namespace, so it landed in the kubeconfig's default namespace instead of the leader-election namespace the manager actually reads from.- The
ValidatingWebhookConfigurationmanifest was being applied with the sharedapplyManifesthelper, which targets wherever the harness's ownKUBECONFIGpoints (the outer cluster) -- caught by code review before it was even hardware-tested, fixed by adding anapplyInnerManifestthat runskubectl apply -f -from inside the pod, against the inner apiserver. webhookcert: no PEM block found in the CA certificate-- a Go shadowing bug:waitForWebhookCert() (caBundle string, err error)discardedpollFor's real return value into_and returned the never-assigned named return instead, because the closure's owncaBundle, ok := ...created a new local variable in its own scope rather than assigning the outer one. Always returned("", nil). Fixed by dropping the named returns and returningpollFor's result directly.error: failed to create secret secrets "...already exists"on a retry after a previous failed run had already created it -- the code was checking for the API's machine-readableAlreadyExistsreason, which the CLI's own printed text (lowercase, human-readable) never contains, so the check could never have caught this reliably. Fixed by rewriting the setup step tokubectl applya small inline Namespace+Secret manifest, which is naturally idempotent and needs no exists-check at all.error: no objects passed to apply-- the real one, found only once the first four were out of the way.kubectl exec <pod> -- <cmd>does not forward the local process's stdin to the remote command unless the outer invocation itself is given-i/--stdin; settingcmd.Stdinon the localexec.Cmdonly feeds the localkubectl execprocess's own stdin, whichkubectl execthen drops on the floor without-i. EveryapplyInnerManifestcall piped a manifest intoinnerKubectl("apply", "-f", "-"), whose remotekubectl applytherefore always saw an immediately-closed, empty stdin. Fixed by adding-ito the one sharedpodExecthat every inner-cluster call goes through.
(The log also shows the expected no matches for kind "TalosMachine"
error repeating -- CRDs are not yet installed on this inner cluster at this point in the
sequence, which is next.)
Removal ordering, verified as far as current code allows
With the controller reachable, installed all five CRDs (not just TalosCluster/
TalosMachine) on the inner cluster and restarted /manager: the first attempt with only
two CRDs installed crashed outright --
Failed to run manager {"error": "failed to wait for talosoperation caches to sync..."}
-- because the manager waits for every informer it starts (TalosOperation and
TalosStorage included) to sync before Start returns, not just the ones a given test
cares about. Then created a TalosCluster (e2e-ha, controlPlane.count: 3) and three
TalosMachine fixtures (role controlplane, approvalState: approved, endpoints
matching the three real node pod IPs) deliberately without a working secretsRef Secret
-- confirmed safe again: all three landed on Ready=False/SecretsBundleUnavailable
without ever touching the real Talos API, and all three picked up
ichor.maesh.dev/machine-removal on their first reconcile, matching
talosmachine_controller.go's "only an approved control plane gets the finalizer" rule.
Quorum-breaking delete, refused via admission even with allow-delete set. A real
finding changed how this had to be tested: CountApprovedControlPlanes lists every
TalosMachine matching role/approval/cluster with no filter on DeletionTimestamp, and
reconcileRemoval's only RemoveFinalizer call is the "TalosCluster already gone"
branch -- the "confirmed safe, drained" success path never clears it either (removal.go's
own doc comment: etcd member removal and the Talos reset are "deliberately not
implemented here"). So a deleted control plane never actually leaves the count, which
means the webhook can never observe a real "3 -> 2 already happened, now try a 2nd" --
every attempt at a cluster's nominal size sees the same nominal count. Isolating the
quorum math this item actually asks about therefore needed one explicit, labeled bypass:
cleared e2e-ha-0's finalizer directly (kubectl patch ... finalizers: null, no
DeletionTimestamp on it yet, so it just vanished) to get a real two-machine count,
simulating "a prior removal already finished" without exercising removal.go's own
(incomplete) sequence. With that real two-machine state:
$ kubectl delete talosmachine e2e-ha-1 # annotated allow-delete=true first
Error from server (Forbidden): admission webhook "vtalosmachine-v1alpha1.kb.io" denied
the request: delete refused: control planes 2 -> 1, below etcd quorum of 2 for 2 members
refused by CheckControlPlaneQuorum at the webhook, annotation notwithstanding -- exactly
the claim this item makes, confirmed for real, with the one caveat above about how the
"2 remaining" state had to be reached.
A second, unplanned finding from the first real delete attempt (e2e-ha-0, 3 -> 2,
while all three originals were still present and controlPlane.count was still 3):
admission allowed it (quorum alone passes 3 -> 2), but reconcileRemoval's own recheck
refused it anyway, for a different reason than quorum:
"delete refused: control planes 3 -> 2, below the declared count of 3; approve a
replacement control plane before removing this one"
The webhook's ValidateDelete only ever calls CheckControlPlaneQuorum, never
CheckControlPlaneCount -- so admission alone is not sufficient evidence that a removal
will actually proceed; the controller's own post-admission recheck inside the lock is a
second, stricter gate (declared count, not just quorum), and it is this second gate --
not quorum -- that refuses every removal attempted while a cluster sits at its declared
size. The architecture doc's "3->4->3, never 3->2" is enforced right here.
Last control plane cannot be deleted. Continuing the bypass (cleared e2e-ha-1's
finalizer too, dropped its approvalState to rejected so it would stop counting, then
deleted it for real -- leaving e2e-ha-2 as the cluster's only approved control plane),
then:
$ kubectl delete talosmachine e2e-ha-2 # annotated allow-delete=true first
Error from server (Forbidden): admission webhook "vtalosmachine-v1alpha1.kb.io" denied
the request: delete refused: last control plane (1 -> 0); delete the TalosCluster instead
Refused at admission, exactly as architecture.md names it. (The e2e-ha-1 deletion
attempt along the way produced the same refusal one layer later, in reconcileRemoval
itself -- "last control plane (1 -> 0)" again, because by the time its own
DeletionTimestamp got processed its approvalState had already flipped to rejected,
so it stopped counting itself and saw only e2e-ha-2 left. Real, but a messier path to
the same evidence than the direct e2e-ha-2 attempt above.)
A single deletion "completing in order," ending with etcd at 2 members: not achievable
with current code, confirmed rather than assumed. Set up the one scenario
checkControlPlaneRemoval can actually pass -- added three more approved control-plane
TalosMachines (e2e-ha-3/4/5) alongside the one real-node-mapped survivor (e2e-ha-2),
bringing the live approved count to 4 against a declared count of 3. Confirmed the
TalosCluster's own Transitioning condition came up correctly from that real data:
{"reason":"EvenControlPlaneCount","status":"True","message":"4 control planes approved right now"}. Set e2e-ha-2.spec.hostname to the real Node name ichor-e2e-ha-2 and
deleted it (annotated): checkControlPlaneRemoval(4, 3) passed both quorum and declared-
count, and drainNode ran for real against the real inner-cluster Node -- cordoned it
(spec.unschedulable: true) and began evicting a real Pod placed there for the test. It
reached:
"removal passed the quorum, last-control-plane and declared-count checks and the node was
drained; etcd member removal and reset are not implemented yet, so the finalizer is held
rather than letting deletion complete while the node may still be an etcd member"
reason: RemovalApproved, status: True
-- the actual terminal state this code can reach, and etcd stayed a healthy 3-member
cluster throughout (checked with talosctl etcd members via test/internal/talosctl's
one-shot-pod pattern, before and after). It never dropped to 2 members, and the
TalosMachine kept its finalizer (still present after RemovalApproved) -- precisely
what the package doc comment already says, now confirmed against a real 3-member etcd
rather than taken on faith.
transitioning blocking a concurrent disruptive operation: the live signal was
confirmed real; the refusal itself was not separately re-verified against real hardware
this pass. The Transitioning condition above came from a genuine List against a real
apiserver, not a mock -- that much is now real-hardware evidence. Exercising the actual
refusal (refuseReboot's acquireDisruptiveLock(..., completesTransition=false)) needs
the main reconcile path to get as far as detecting a reboot-requiring drift, which needs
a working secrets bundle Secret -- setting one up correctly (the full Talos/Kubernetes/
etcd CA bundle pkg/render.BuildConfig expects) is a materially bigger lift than
anything else in this pass, and internal/controller/acquire_guard_test.go and
removal_test.go already cover acquireDisruptiveLock's transitioning refusal against a
real (envtest) apiserver. Deferred rather than rushed.
Killing the controller mid-removal and confirming it resumes correctly from status --
the highest-value item, fully confirmed. Mid the e2e-ha-2 drain above, polled until
the Node showed unschedulable: true and the test Pod showed a deletionTimestamp while
still Running (it traps SIGTERM and ignores it, so eviction's grace period keeps it
alive long enough to interrupt) -- a real, observed mid-drain window, not a guess at
timing. At that point:
$ kubectl exec ichor-e2e-controller -- kill -9 293 # /manager's pid
Confirmed etcd healthy and unchanged (still the same 3 members) with the controller dead.
Restarted /manager the same way it was started originally; it re-acquired the leader
lease under its own stable identity (ichor-controller) after the old lease expired (~17s,
consistent with the standard 15s lease duration) -- no HeldError, exactly
clusterlock.Lock.Acquire's documented self-resumption case. It then picked e2e-ha-2
back up, recomputed checkControlPlaneRemoval from scratch (still passing: the live count
never changed while it was dead, for the same reason noted above), re-ran drainNode
against the real Node, and reached the same RemovalApproved terminal condition quoted
above on its own, with no manual nudge beyond retriggering its reconcile queue. etcd was
checked again afterward and was still the same healthy 3-member cluster
(ichor-e2e-ha-0/1/2, no learners) it had been before the kill. The interrupted deletion
did not touch, let alone break, the real etcd cluster underneath it -- confirmed, not
inferred from the code's own doc comment alone.
Pinning the target cluster (follow-up)
The KUBECONFIG-only check above was not enough: with KUBECONFIG set to a
multi-context file, kubectl without --context still used its
current-context. Every outer-cluster kubectl call in the harness now goes
through test/internal/kube.Command, which always adds
--context $ICHOR_E2E_KUBE_CONTEXT and builds a command that fails to start
when the variable is unset. Deletes are additionally refused unless they carry
-n ichor-e2e and name their objects (no -A, --all, -l, -f). Inner
cluster calls use kube.InnerCommand(<kubeconfig>, ...) and never delete.
talosctl runs only in pods; test/internal/talosctl refuses a call with no
explicit --nodes or with 192.168.0.201-203 anywhere in its arguments.
Environment variables an e2e run needs:
| Variable | Meaning |
|---|---|
KUBECONFIG | kubeconfig file containing the outer cluster |
ICHOR_E2E_KUBE_CONTEXT | context in it to target; required, no default |
make test-e2e-prereqs and the harness binary both refuse to run when
ICHOR_E2E_KUBE_CONTEXT is unset or is not a context in the kubeconfig.