Status: active
Operator guide for running multiple K3s clusters in one account: bootstrap, VIP-backed api_endpoint, kubeconfig retrieval, and cross-cluster operator workflows. An HA control plane (Phase 4) is not implemented — see the banner below. Placing workers in a chosen cluster (Phase 3) is implemented as of IMP-a5f236e8cc56.
Audience: operators running multi-environment fleets (prod + staging, multi-region, multi-tenant); SREs managing K3s upgrades.
Bootstrapping a second cluster works, and so does putting a worker in a specific one. Set
target_cluster_idin thek3s-agentmodule assignment'sconfig; the platform surfaces it to the agent on the existingruntime/k3s_agent/configendpoint,k3sd.AgentManagerfetches it each tick and forwards it onphase=join_request. See Phase 3 for the working steps.If you omit it (or it names a cluster the platform can't back — a foreign-account id, or one in
errorstatus), the OLD single-cluster behaviour still applies: with exactly one non-error cluster in the account the join resolves without a target; with more than one it is refused —AmbiguousClusterError, withsystem.k3s_ambiguous_cluster_join_refusedemitted at severityhigh. The worker does not join the wrong cluster in that case either; no node is produced at all. That refusal behaviour — and the earlier withdrawal explaining it — are kept visible in the historical section below, since an operator on an older node image (pre-producer) still hits it.Phase 4 (HA control plane) is NOT IMPLEMENTED either — now confirmed. An earlier revision of this banner reported that as an unverified suspicion. It has since been verified at HEAD: a second
k3s-serverhas no join path (WriteJoinConfigis defined only onShellAgentApplier, andBootstrapConfig.InstallArgshas no seam for--server/--token), so it bootstraps a second cluster instead of extending the first — which is exactly what trips the refusal above for every worker you add afterwards. An operator following this page in order manufactures the failure it warns about. Workers can only be added while the account holds exactly one cluster, and both Phase 2 (bootstrap cluster B) and Phase 4 end that window permanently. Add every worker you need first; the phase numbering is not a safe execution order.Unlike Phase 3, Phase 4 is not queued behind IMP-a5f236e8cc56. K3s HA is parked: the platform does not do this, and there is no work scheduled to make it. The reason is the useful part — the first server bootstraps without
--cluster-init, so its datastore is not etcd and could not accept a second control-plane member even if a join path existed. Supporting HA would mean changing how every already-provisioned cluster bootstraps, which is why it is parked rather than queued. Plan single-server clusters, not an HA migration. The withdrawn steps are kept visible in Phase 4, alongside what each of them actually does.
Multi-cluster is the right architecture when:
- You need environment isolation (prod vs staging) — separate clusters means independent upgrade cadences and failure domains
- You're running multi-region workloads — a cluster per region keeps API latency low
- You're providing multi-tenant Kubernetes-as-a-service — one cluster per tenant means clean trust boundaries (and a separate
Sdwan::Networkper tenant lets you use per-tenantpod_subnet_prefixfor encrypted-pod-plane isolation — see "Per-tenant pod plane" below) - You need independent K3s versions across workloads
For multi-tenant K8s-as-a-service, the cleanest isolation pattern is:
- One
Sdwan::Networkper tenant — each tenant's k3s nodes peer on their own /64 overlay - A distinct
pod_subnet_prefixper network — e.g., tenant A gets10.42.0.0/16, tenant B gets10.43.0.0/16 - k3s clusters bootstrap with
cni_plugin: "flannel"— the platform stamps each cluster'sflannel_iface/flannel_backend=host-gw/cluster_cidrfrom its network'spod_subnet_prefix
Result: tenant A's pod traffic flows over A's own wg device (encrypted by A's WG tunnels), tenant B's over B's (encrypted by B's WG tunnels) — the device is per host+network, wg-sdwan-<short_id>, not derived from the tenant's network handle. No shared transport for pod traffic. The platform's overlap validation refuses any operator attempt to assign the same pod_subnet_prefix to two networks in the same account.
For ovn-Kubernetes clusters (heavyweight tenants), the per-tenant pod_subnet_prefix is ignored — OVN owns its own pod-network layer. ovn-K8s tenants get OVN-tunnel-level encryption automatically.
Stick to single-cluster when:
- Workloads are homogeneous and small (<50 pods total)
- You need cross-workload service discovery (a single cluster's service mesh is simpler than cross-cluster federation)
- Cost is paramount — N control planes cost N× more than 1
// Step 1: provision a NodeInstance for the control plane
platform.system_create_node({ hostname: "k3s-prod-server-1", node_template_id: "<k3s-server-template>", ... })
platform.system_provision_instance({ node_id: "<node-id>", ... })
// Step 2: ensure SDWAN peer is attached (REQUIRED for slice 3 VIP failover)
platform.system_sdwan_attach_peer({ network_id: "<sdwan-net>", node_instance_id: "<instance-id>" })
// Step 3: assign the k3s-server module
platform.system_assign_module_to_template({
template_id: "<k3s-server-template>",
// The template-assignment verbs take the NodeModule UUID, not its name — system_list_modules returns { id, name }.
module_id: "<k3s-server-module-id>"
// No target_cluster_id needed for the bootstrap node — it CREATES the cluster
})
// → wait ~90s for cluster bootstrap
// Step 4: verify cluster appears
platform.kubernetes_list_clusters()
// → { clusters: [{ id, name, flavor: "k3s", status: "active", api_endpoint, ... }] }The cluster's api_endpoint is an Sdwan::VirtualIp (slice 3) — https://[fd00:abcd:1::100]:6443. The bootstrap server is the VIP's primary holder.
Same steps as Phase 1, but with a different Template:
platform.system_create_node({ hostname: "k3s-staging-server-1", node_template_id: "<k3s-staging-template>", ... })
platform.system_provision_instance({ node_id: "<node-id>", ... })
platform.system_sdwan_attach_peer({ network_id: "<sdwan-net>", node_instance_id: "<instance-id>" })
platform.system_assign_module_to_template({
template_id: "<k3s-staging-template>",
module_id: "<k3s-server-module-id>"
})
// Wait ~90s, then:
platform.kubernetes_list_clusters()
// → { clusters: [
// { id: "cluster-prod-id", name: "k3s-prod-server-1", status: "active" },
// { id: "cluster-staging-id", name: "k3s-staging-server-1", status: "active" }
// ] }Two clusters now exist; their api_endpoint VIPs are different /128 addresses.
Set target_cluster_id in the k3s-agent module assignment's config,
and the worker joins the cluster you named. The platform surfaces that
value to the calling node on the existing runtime/k3s_agent/config
endpoint (only when it names a Devops::KubernetesCluster in the node's own
account whose status is not error; otherwise it surfaces empty).
k3sd.AgentManager refreshes TargetClusterID from that endpoint each tick,
before Reconcile, and forwards it on phase=join_request
(agent_manager.go:55 declares the field, :160 consumes it,
runtime/service.go is the producer). This is a NEW, dedicated channel —
k3sd.ModulesAPI.AssignedModules still hands the reconcilers module names
only, unchanged; the value is fetched separately by
k3sd.HTTPAgentConfigClient. Server-side, handle_join_request
(runtime_handshake_handlers.rb) forwards whatever arrives into
join_request!, same as it always has — this page's changes are entirely
about the agent finally having something to send it.
platform.system_provision_instance({ node_id: "<worker-node-id>", ... })
platform.system_assign_module_to_template({
template_id: "<worker-template>",
module_id: "<k3s-agent-module-id>",
config: {
target_cluster_id: "cluster-prod-id" // delivered to the agent
}
})
// → wait ~30-60s (one agent tick) for the config fetch, then the next
// Reconcile tick's join_request; the worker joins cluster-prod-idValidation happens on the same fetch, not at assignment time. An id that
doesn't name a live cluster in the node's own account (foreign-account, or a
cluster in error status) is surfaced as empty rather than passed through —
the assignment call still succeeds either way, so check
kubernetes_list_nodes to confirm the worker actually landed where you
intended, not just that the assign call returned 200.
Changing target_cluster_id after a join does not move the worker. Only
a fresh join (a worker that hasn't joined yet, or one that's had its join
state cleared — cleanup, a stop, or a k3s reinstall) consults the field. An
already-joined worker keeps re-reporting readiness against its own cached
joinedClusterID (agent_manager.go:179, 200), which the platform accepts
as the target regardless of what the assignment config says now. To move a
worker, remove it from the old cluster and provision a fresh one against the
new target_cluster_id.
Single-cluster accounts are unaffected. With exactly one non-error
cluster, the worker joins it without a target — target_cluster_id is
optional in that case, not required:
platform.system_provision_instance({ node_id: "<worker-node-id>", ... })
platform.system_assign_module_to_template({
template_id: "<worker-template>",
module_id: "<k3s-agent-module-id>"
})Before the producer above landed, this section described Phase 3 as withdrawn. Kept visible so an operator who read an earlier revision of this page recognises the claims they may have acted on:
| Withdrawn claim | What is actually true |
|---|---|
"Without metadata.target_cluster_id, agents auto-select the most recent active cluster" |
There is no most-recent-active fallback. resolve_membership_cluster! (kubernetes_cluster_provisioner_service.rb:351) auto-selects only when there is exactly one candidate; with more than one and no (or an unresolvable) target it raises AmbiguousClusterError and the join fails. "Candidate" is where.not(status: "error") — so a cluster in pending, bootstrapping, degraded or disconnected counts toward the ambiguity, not just an active one. Only error is excluded. |
| "new workers will join the wrong cluster if you have multiples" | With no usable target, no node is produced at all — the platform emits system.k3s_ambiguous_cluster_join_refused at severity high (kubernetes_cluster_provisioner_service.rb:329) and returns 409. With a usable target (the working case above), the worker joins the NAMED cluster, never a guessed one. |
target_cluster_id: "cluster-prod-id" // ← REQUIRED for multi-cluster |
Required only when the account has more than one non-error cluster AND you need a SPECIFIC one — and, as of IMP-a5f236e8cc56, it is deliverable from the agent (see the working steps above). |
"The agent reads target_cluster_id from its module assignment metadata at boot, passes it through to the platform's runtime/handshake POST" |
Close, but wrong about the mechanism and timing: the agent fetches it from runtime/k3s_agent/config on every PostSend tick (not once at boot), via k3sd.HTTPAgentConfigClient, and forwards the fetched value on the runtime/handshake phase=join_request POST. k3sd.AgentManager.TargetClusterID is declared (agent_manager.go:55) and consumed (agent_manager.go:160, passed to JoinRequest), and is now written each tick by runtime/service.go's PostSend hook — the same pattern used for ServerManager.Bootstrap. |
"Agent must restart to pick up changes to target_cluster_id in module metadata" |
No restart needed — the fetch happens every tick (roughly every 30-60s), so a config change is picked up on the next tick, well before the agent's join_request fires for a fresh join. |
What was wired on one side only now has its missing write. The
server-side validation described above — cluster exists, same account, not
in error state — was already in place; IMP-a5f236e8cc56 added the agent's
producer (k3sd.HTTPAgentConfigClient + the runtime/service.go refresh)
that finally delivers a value for it to validate.
A second k3s-server does not join the first cluster. It bootstraps a second
cluster. Assigning k3s-server to a second NodeInstance is not an HA
operation — it is a second bootstrap. And because the Phase 3 refusal counts
clusters, running this phase is what makes every later worker join fail.
This page lists Phase 3 before Phase 4, but the causal dependency runs the
other way: the phase numbering is not a safe execution order. Add every worker
you need while the account still holds exactly one cluster.
Three facts, each verified in code:
- No join path exists.
k3sd.ServerManagernever callsJoinRequestand never referencesTargetClusterID— it callsBootstrapand then reports ready against its ownbootstrappedForcluster (server_manager.go:193, 215). The only writer ofK3S_URL/K3S_TOKENisWriteJoinConfig, defined onShellAgentApplier(shell_applier.go:318), the worker-side applier. TheServerApplierinterface (applier.go:140) has no equivalent method at all. - The install has no seam for one.
ShellServerApplier.InstallK3sServerrunscurl -sfL https://get.k3s.io | INSTALL_K3S_EXEC=server sh -s -plus whateverBootstrapConfig.InstallArgs()returns (applier.go:81), and that emits CNI / flannel-overlay flags only — itsdefault:arm returnsnil, false. There is no path for--server,--token, or--cluster-init. - The platform then creates a new cluster row.
bootstrap!'s idempotency check keys onnode_instance_id(kubernetes_cluster_provisioner_service.rb:135), so a different NodeInstance falls straight through and a second cluster is created —Devops::KubernetesCluster.create!(status: "bootstrapping")with its own VIP, aKubernetesNodewithrole: "server", andnode_count: 1(:227,:242,:252).
K3s HA is parked, not pending. Phase 3 is a missing write awaiting a
producer (IMP-a5f236e8cc56); Phase 4 is not, and no work is scheduled to make
it. Even with a join path wired up a second server could not join: the first
server bootstraps without --cluster-init, so its datastore is SQLite, not
embedded etcd, and there is no etcd cluster to accept a second control-plane
member. (The BootstrapConfig doc comment in agent/internal/k3sd/applier.go
says the same — a zero-value config is the upstream default with a SQLite via
kine datastore — and the exec string in fact 2 is what runs.) Supporting HA
would therefore change how every already-provisioned cluster bootstraps,
which is why it is parked rather than queued. Design for single-server
clusters; do not plan around a future migration to HA.
The steps below are withdrawn, kept visible so you can recognise them if you have already run them:
// WITHDRAWN — this does not add a server to cluster-prod-id. It bootstraps a
// second, independent cluster, and that is what makes every later k3s-agent
// join refuse. See Phase 3.
platform.system_create_node({ hostname: "k3s-prod-server-2", ... })
platform.system_provision_instance({ node_id: "<node-2-id>", ... })
platform.system_sdwan_attach_peer({ network_id: "<sdwan-net>", node_instance_id: "<instance-id>" })
platform.system_assign_module_to_template({
template_id: "<k3s-server-template>",
module_id: "<k3s-server-module-id>"
// config: { target_cluster_id } is stored, but k3s-server has no producer
// for it (unlike k3s-agent as of IMP-a5f236e8cc56 — see Phase 3): the
// k3s-server reconciler never issues a join_request, so there is no
// phase where a target cluster could apply. Setting it here does nothing.
})
// WITHDRAWN — this listing returns one node, not two. The new server is the
// sole node of a different cluster; `kubernetes_list_clusters` is where it
// shows up.
platform.kubernetes_list_nodes({ cluster_id: "cluster-prod-id" })| Withdrawn claim | What is actually true |
|---|---|
| "Wait ~120s for the second server to join etcd" | Nothing joins, at any timeout. ServerManager runs the same bootstrap path the first server ran, and bootstrap! creates a new Devops::KubernetesCluster because its idempotency check keys on node_instance_id (kubernetes_cluster_provisioner_service.rb:135). |
| "The second server is now a VIP failover candidate" | Not for cluster A's VIP. add_to_vip_failover_candidates! (kubernetes_cluster_provisioner_service.rb:628) runs from register_node_join!, which resolves membership from the agent's own cached bootstrappedFor id — the new cluster, where this server is already the primary holder, so the call returns without adding anything. Cluster A's failover_holder_peer_ids is unchanged. |
| "the second server joins etcd; the existing cluster keeps running" | The existing cluster does keep running, and is untouched. But adding HA mid-life is not an online operation, because it is not an HA operation. |
| "cluster goes from 1-replica to 3-replica (etcd default)" | Neither cluster is an etcd cluster. Without --cluster-init the k3s server uses its SQLite datastore; there are no replicas to count. |
What still works. Single-server clusters are unaffected, and the VIP
machinery itself is real — the api_endpoint is a VIP, sdwan_vip_failover and
system_sdwan_failover_virtual_ip behave as documented, and
sdwan_vip_reachability_sensor fires system.sdwan_vip_unreachable when the
holder goes silent. What is missing is a second candidate to fail over to:
with one server per cluster, failover_holder_peer_ids stays empty and
promotion has nowhere to go.
Inspect the VIP (still accurate, single-holder):
platform.system_sdwan_get_virtual_ip({ virtual_ip_id: "<cluster-vip-id>" })
// → { virtual_ip: { id, holder_peer_ids: [<the one server>],
// failover_holder_peer_ids: [], primary_holder_peer_id, ... } }
// Manual failover promotes the head of failover_holder_peer_ids to holder.
// Single required param: virtual_ip_id (no dry-run / scoring mode). With an
// empty candidate list there is nothing to promote.
platform.system_sdwan_failover_virtual_ip({ virtual_ip_id: "<cluster-vip-id>" })platform.kubernetes_get_kubeconfig({ cluster_id: "cluster-prod-id" })
// → {
// kubeconfig: "apiVersion: v1\nclusters:\n - cluster:\n server: https://[fd00:abcd:1::100]:6443\n certificate-authority-data: ...\n ...",
// api_endpoint: "https://[fd00:abcd:1::100]:6443"
// }The api_endpoint is the slice 3 VIP. kubectl traffic goes to the VIP rather than the server's own address, so the address in your kubeconfig does not change if the cluster is ever rebuilt onto a new bootstrap node.
Save and use:
# Set up multiple kubectl contexts
echo "$KUBECONFIG_PROD" > ~/.kube/k3s-prod.yaml
echo "$KUBECONFIG_STAGING" > ~/.kube/k3s-staging.yaml
# Use one
kubectl --kubeconfig ~/.kube/k3s-prod.yaml get nodes
kubectl --kubeconfig ~/.kube/k3s-staging.yaml get nodes
# Or merge into one file with switchable contexts
KUBECONFIG=~/.kube/k3s-prod.yaml:~/.kube/k3s-staging.yaml kubectl config view --merge --flatten > ~/.kube/config
kubectl config use-context k3s-prodOperators must be on the same SDWAN network or have a federation route to reach the api_endpoint VIP.
// List all clusters across the account
platform.kubernetes_list_clusters()
// Get specific cluster details (status, version, node count)
platform.kubernetes_get_cluster({ cluster_id: "cluster-prod-id" })
// List nodes in a cluster (control-plane + workers)
platform.kubernetes_list_nodes({ cluster_id: "cluster-prod-id" })
// Decommission an entire cluster (cascades to all member nodes)
platform.kubernetes_decommission_cluster({ cluster_id: "cluster-staging-id" })
// → cascade-deletes all member KubernetesNode rows; underlying NodeInstances are NOT terminatedFor per-cluster module rolling upgrades, scope by template. The
system-rolling-module-upgrade skill PLANS ONLY — no autonomy reconciler
drives it, and it returns no batches to walk: the upgrade is FLEET-ATOMIC
(see
../tutorials/06-rolling-upgrade.md for
the manual procedure that works). Fleet Autonomy / CVE Responder bind the
skill, so an approved action produces the plan below and stops there. Its
input contract, scoped to one cluster's server template:
Per-cluster staging needs a per-cluster module row. Because the pointer is per-module, giving
cluster-proda different k3s version fromcluster-stagingrequires twoNodeModulerows (each with its owncurrent_version_id), or replacing instances out of an instance pool. A percentage never delivered this and no longer pretends to.
This is not an anti-pattern you can avoid — it is the only shape available. VIP failover would need ≥2 servers in one cluster, and Phase 4 cannot produce that, so every cluster is single-server:
- ✅ Works for development / staging / small workloads
- ❌ Cannot survive bootstrap-node loss —
kubectland workerK3S_URLconnectivity break the moment the only server dies - ❌ Has the VIP allocated, but with an empty candidate list there is nothing to promote, so failover raises rather than switching
If you start with single-server, you stay single-server. There is no supported way to add a second control-plane node — see Phase 4, where the mid-life "add HA" snippet below is withdrawn and each of its claims is set against what actually happens. Plan around the limitation: treat loss of the bootstrap node as a cluster rebuild, not a failover, and size the blast radius accordingly.
Do not rely on the /persist/var/lib/rancher/k3s/ durability story that
other pages in this tree describe. mount.EnsurePersistentVar — the bind mount
that story depends on — has no production caller (agent/internal/mount/bind.go:16;
agent/internal/runtime/softreboot.go:143-148 says so in as many words), so
/var is not a bind mount on a current node. Whether k3s state actually
survives a given reboot is unverified here; do not plan around it either way.
// WITHDRAWN — "adding HA mid-life". This does not extend the existing
// cluster; it bootstraps a second one. See the Phase 4 withdrawal table.
platform.system_provision_instance({ node_id: "<new-server-node>", ... })
platform.system_sdwan_attach_peer({ ... })
platform.system_assign_module_to_template({
template_id: "<existing-server-template>",
module_id: "<k3s-server-module-id>"
// config: { target_cluster_id } withdrawn — not delivered; see Phase 4
})
// → a second cluster, holding one node. The original cluster is unchanged,
// and every later k3s-agent join is now refused (Phase 3).| Symptom | Cause | Fix |
|---|---|---|
Worker does not join at all, system.k3s_ambiguous_cluster_join_refused (severity high) in the event stream |
Two or more non-error clusters in the account, and the join carries no usable target_cluster_id — either the k3s-agent module assignment's config has no target_cluster_id set, or the one it names doesn't resolve (foreign-account, or error status) |
Set target_cluster_id in the assignment config to the intended cluster's id (see Phase 3), confirm it resolves in kubernetes_get_cluster, and wait one agent tick before the next join attempt. Do not go looking for a misplaced node from a prior refusal; none was created |
Worker stuck in join_request phase |
API endpoint VIP unreachable | Verify worker is on the same SDWAN network as the cluster's bootstrap server |
Worker stuck in join_request, "bad token" |
Token rotated since last cache | Restart powernode-agent on the worker; or re-fetch via terminate + reprovision. joinedClusterID (agent_manager.go:179, 200), which the platform accepts as the target regardless of the assignment's current target_cluster_id. Anything that clears that cache — cleanup, a stop, or a k3s reinstall (agent_manager.go:155, 221, 230) — sends the next JoinRequest re-consulting target_cluster_id; if that's unset or unresolvable and a second cluster exists, the join is then refused. A reprovision without setting target_cluster_id can therefore land a worker on the wrong side of that refusal while a second cluster exists |
Second k3s-server never shows up in kubernetes_list_nodes for the first cluster |
It never tried to join. The k3s-server reconciler has no join path, so it bootstrapped a second cluster instead | Expected outcome, not a misconfiguration — see Phase 4. kubernetes_list_clusters will show the new server as the sole node of a new cluster. Nothing on the assignment changes this |
| VIP doesn't fail over after primary loss | Every cluster is single-server (Phase 4 is not implemented), so failover_holder_peer_ids is empty and there is nothing to promote; or sdwan_vip_failover is blocked by require_approval |
Check the approval queue. If the cluster genuinely has one server, this is expected, and adding another server will not fix it — it makes a second cluster |
kubectl works but pods can't reach external services |
Pods using flannel/CNI default route | Verify worker Nodes have proper egress. This is a pod egress concern, distinct from the encrypted pod-to-pod overlay (flannel-over-SDWAN, which ships per "Per-tenant pod plane" above). |
Multiple clusters but kubernetes_list_clusters shows only one |
A cluster was decommissioned — that is a HARD delete, not a hidden row — or the caller is scoped to a different account | Nothing can un-hide it. Both decommission paths call cluster.destroy! (server/app/services/ai/tools/kubernetes_provisioning_tool.rb, extensions/system/server/app/services/system/executors/runtime/decommission_k3s_cluster.rb), devops_kubernetes_clusters has no soft-delete or archival column, and the member devops_kubernetes_nodes rows cascade away with it. What survives is the audit row: Devops::KubernetesCluster includes Auditable, whose before_destroy writes an AuditLog with action: "deleted", resource_type: "Devops::KubernetesCluster", resource_id set to the dead cluster id, and old_values carrying its name, slug, status, environment and node_count. Read it at GET /api/v1/audit_logs?resource_type=Devops::KubernetesCluster — account-scoped unless the caller holds admin.audit.read, and resource_id is not a supported filter, so filter on the type and read the id out of the payload. If no such row exists, nothing was decommissioned and this is account scope — check which account the token resolves to. The same hard delete is why a decommissioned cluster stops counting toward AmbiguousClusterError: the candidate set is a live where(account_id:).where.not(status: "error") query (kubernetes_cluster_provisioner_service.rb), which a destroyed row cannot enter |
Kept visible, per this page's treatment of the Phase 3 and Phase 4 withdrawals, so an operator who already tried it recognises what they ran.
| Withdrawn claim | What is actually true |
|---|---|
"Check ?include_decommissioned=true filter" |
No such parameter exists on any cluster-listing surface, and nothing could back one. Decommission is a hard delete on both paths and the clusters table has no deleted_at, so there is no retained row for a filter to reveal. The surviving record is the AuditLog row described in the Troubleshooting table above |
When an operator chats "set up prod and staging K3s" / "add a worker to staging cluster" / "decommission staging cluster":
- For multi-cluster bootstrap, surface the Phase 1 + 2 sequence. For "add a worker to staging cluster", offer Phase 3: set
target_cluster_idin thek3s-agentmodule assignment'sconfigto the target cluster's id. Warn that once a second cluster exists, an UNCONFIGURED k3s-agent join (notarget_cluster_id, or one that doesn't resolve) is refused rather than guessed (see the banner at the top of this page) - Do not propose an HA control plane. Say plainly that Phase 4 is not implemented: a second
k3s-serverbootstraps a second cluster rather than joining the first, and doing so refuses every subsequent worker join. If the operator asks for HA anyway, say that K3s HA is parked — not a queued defect and not on a roadmap — and steer the design to single-server clusters - For decommission, use
kubernetes_decommission_clusterwithrequest_confirmation(destructive) - After each phase, surface the relevant cluster status from
kubernetes_get_cluster
The Concierge filter includes kubernetes_* actions — this entire workflow is in scope.
USE_CASE_MATRIX.md— use case 3 (multi-cluster K3s); use case 7 (hybrid persistent + ephemeral)CONTAINER_RUNTIMES.md— Phase 2 K3s lifecycle referencerunbooks/node-provisioning.md— Node + NodeInstance lifecycle (each cluster member is a NodeInstance)runbooks/sdwan-network-setup.md— SDWAN setup (required for cluster api_endpoint VIPs)SKILL_EXECUTORS.md—sdwan_vip_failover. Note:provision_clusterprovisions N instances of a template (up to 50); pointed at ak3s-servertemplate that is N clusters, not one cluster of N servers — see Phase 4
Last verified: 2026-09-17 — Phase 3 re-verified against the agent and provisioner source and restored (IMP-a5f236e8cc56: the agent-side target_cluster_id producer landed). Phase 4 re-verified 2026-09-01 and remains withdrawn/parked. Phases 1, 2, 5, 6 not re-verified in that pass.