Status: active
Step-by-step operator guide for the full Node + NodeInstance lifecycle: from "I have a Template" through "the instance is decommissioned and rows cleaned up." Includes per-state error recovery and the LocalQemuProvider variant for offline / smoke-test environments.
Audience: external operators (open-source consumers), internal Powernode operators, on-call SREs handling stuck instances.
| Phase | What happens | Typical duration | MCP entry point |
|---|---|---|---|
| 1. Create Node | Logical row representing a future-or-existing host | <1 s | system_create_node |
| 2. Provision instance | Provider boots a VM with the netboot image | 30 s – 10 min | system_provision_instance |
| 3. Bootstrap | Agent installs, mTLS handshake, module reconcile | ~90 s cold (5-10 min on slow providers) | none — agent-driven |
| 4. Run | Heartbeats, reconcile loop, task lease | indefinite | system_get_instance |
| 4b. Cordon | Unschedulable, still running — reversible; new pool acquires, CI runner leases and fleet missions stop landing on it, the replica reconciler counts it as gone (and provisions its replacement) and takes it first on a scale-in, nothing running is touched | <1 s (approval-gated) | system_cordon_instance / system_uncordon_instance |
| 5. Drain | Cordon + STOP — pool member fenced out of the allocator, then the VM is stopped; workload relocation is still manual and must happen FIRST | seconds (the call itself) | system_drain_instance |
| 6. Decommission | Provider VM destroyed, FK cascades fire | <1 min | system_terminate_instance |
The NodeInstance AASM has 9 states: pending, provisioning, starting,
running, stopping, stopped, rebooting, terminated, error. There is
no draining state, and system_drain_instance does not drive the instance
toward one: it walks running → stopping → stopped, the same walk
system_stop_instance drives (Phase 5 below). draining is a pool_state on
pooled instances — a different column, and what the drain's CORDON half and a
cordon-only system_cordon_instance both write —
not an instance AASM state. There is also
no failed state — the terminal
failure state is error. The "bootstrapping" box below is the agent-driven boot
window within the provisioning state, not a separate AASM state.
┌──────────────┐
│ (no instance)│ Node row exists; no provider VM yet
└──────┬───────┘
│ system_provision_instance
▼
┌──────────────┐ AASM: pending → mark_provisioning → provisioning
│ provisioning │ Provider creates VM; netboot fetches kernel + initramfs
└──────┬───────┘
│ (agent boot window: enroll CSR → mTLS,
│ modules pulled, fs-verity verified, erofs mounted)
│ ╲ provider error / boot failure
│ first phase=ready heartbeat ╲ → mark_errored
▼ ▼
┌──────────────┐ AASM: mark_running ┌──────────┐
│ running │ heartbeats every 30s │ error │ terminal failure
└──────┬───────┘ task lease ready └────┬─────┘ (no orphaned row)
│ │ terminate
│ system_terminate_instance (hard). │ (allowed from error)
│ system_stop_instance and │
│ system_drain_instance both stop it │
│ (→ stopped, row + disk retained). │
▼ ▼
┌──────────────────────────────────────────────────┐
│ terminated │
│ Cascade FKs: Devops::DockerHost / KubernetesNode│
│ cleanup, Vault TLS revoked, Sdwan::Peer removed │
└──────────────────────────────────────────────────┘
The
terminateevent transitions from every non-terminal state —pending,provisioning,starting,running,stopping,stopped,rebootinganderror(server/app/models/system/node_instance.rb:199-201), so an instance stuck mid-provisioningcan be terminated directly. This runbook previously said it reached onlyrunning/stopped/error; that was the pre-fix shape, corrected under audit 2026-06-09 finding F4-02 (see the model comment at:194-198). See Per-state error recovery for the other ways to clear a stuck instance.
The Node is a logical row. No provider resources are touched here.
platform.system_create_node({
name: "edge-tokyo-01", // REQUIRED — unique within the account
template_id: "<node-template-id>", // REQUIRED — composes assigned modules
description: "Tokyo CDN edge", // optional
enabled: true, // optional; default true
worker_id: "<worker-id>", // optional — Worker that services this node's tasks
public_address: "203.0.113.10", // optional
allocate_public_ip: false, // optional; default false
config: { // optional JSONB — the only free-form field
"owner": "edge-team",
"purpose": "tokyo-cdn-edge"
}
})
// → { node: { id, name, template_id, worker_id, enabled, template_name,
// instance_count, module_count, created_at, ... } }The example above previously passed
account_id,hostname,node_template_id,node_platform_id,node_architecture_id,lifecycle_classandmetadata— every key it showed.system_create_nodedeclares only the eight parameters above, and undeclared keys are dropped without an error, so the old call did not fail in a way that told you what was wrong. They are listed here rather than deleted, because an operator who planned around one of them needs to see it withdrawn:
Old key What is actually true account_idNot a parameter on either surface. The node is created in the caller's authenticated account context. hostnameThe column is name(system_nodeshas nohostname). Unique per account.node_template_idRenamed to template_idon MCP create only. REST create takesnode_template_id, and so doessystem_update_node.node_platform_idNot on a Node. The platform is chosen on the NodeTemplate ( system_node_templates.node_platform_id,NOT NULL) — pick it withplatform.system_create_template.node_architecture_idNot on a Node or a template. It follows from the platform ( system_node_platforms.node_architecture_id,NOT NULL).lifecycle_classNOT IMPLEMENTED as an argument — see below. metadataNo such column. config(JSONB) is the free-form field, on both surfaces.
lifecycle_class — you never set it on a Node, and the column is gone
system_nodes.lifecycle_class no longer exists. IMP-19843220ac68 settled it as
RETIRED — no surface ever accepted it, nothing ever read it — and took step 1:
both writers stopped, column nullable with no default. IMP-f2a7a729d39b took
step 2 one deploy window later: the column, its check constraint
(persistent|ephemeral|spot), its index, System::Node::LIFECYCLE_CLASSES and
the model validation are all dropped. Where its value used to come from is not
what this runbook used to say either:
- You cannot set it on a node you create.
system_create_node's executor slices onlydescription, enabled, worker_id, public_address, allocate_public_ip, config(system_fleet_tool.rb), and REST'snode_paramsdoes not permit it (nodes_controller.rb). A node created either way records no class at all; before the retirement it took the column default,persistent. - You cannot change it afterwards. REST create and update share one
node_paramspermit list, andsystem_update_nodetakes the MCP create surface withtemplate_idrenamed tonode_template_id. None of them includeslifecycle_class. So the old "treat the class as fixed in practice" advice was right about the outcome and wrong about the reason: it is not that you should not change it, it is that through MCP or REST you cannot. - You cannot read it back. Neither
System::NodeSerializer(REST) nor the MCP node serializer ever emittedlifecycle_class, so no API response ever told you a node's class — and there is nothing left in the DB to see either. - The class of a machine lives on the InstancePool, not on the Node.
System::InstancePoolService#provision_warming_member!used to copypool.lifecycle_classonto each member's Node; that copy is gone. A pool's class is still constrained toephemeral|spotand is still settable, readable and rotatable — a member Node reaches its pool throughconfig["instance_pool_id"], which is where anything that ever wants to branch on a machine's lifetime must look. Pool members mostly appear without you:System::InstancePoolReplenisherJobis a 60-second Sidekiq cron that POSTs replenish for everyactiveordrainingpool, so an active pool withtarget_size >= 1mints members unattended; the manual triggers for the same path areplatform.system_replenish_instance_pooland RESTPOST /api/v1/system/instance_pools/:id/replenish. You choose the class on the pool (platform.system_create_instance_pool), never on the node. - Both former writers are gone:
System::PlatformDeploymentOrchestratorhardcoded"persistent"(the old default, so it moved nothing) and the pool service copied the pool's value; theprovision_full_stackskill executor and the fulfillment orchestrator never set it at all. The last writer, thedb/seeds/example_multi_tenant.rbdev seed, went with the column in step 2. - Nothing reads it, and nothing ever did. A tree-wide search of
server/,extensions/,worker/and the Go agent finds no consumer of a Node'slifecycle_class; the model comment's "short-circuit expensive bootstrap for short-lived instances" was never built. That, not any single defect, is why the column was retired rather than wired: stopping the writes without also removing theNOT NULL DEFAULT 'persistent'would have left every pool member claiming to be persistent, which is worse than unread.
One other table carries a same-named column with a different value set — do
not transfer conclusions between them. A third column used to share the name and
no longer does (IMP-1e2e7b43b083 renamed it to lease_class), because it was a
different axis, not a narrower value set:
| Column | Values | Set by |
|---|---|---|
system_nodes.lifecycle_class (dropped) |
was persistent|ephemeral|spot |
nothing — the column no longer exists. Never settable through any surface, never read; the writers were removed in step 1 (IMP-19843220ac68) and the column, its CHECK constraint and its index dropped in step 2 (IMP-f2a7a729d39b) |
system_instance_pools.lifecycle_class |
ephemeral|spot only — no persistent |
platform.system_create_instance_pool, the Instance Pools UI, and GitOps fleet.yaml |
system_node_instances.lease_class (not a lifecycle class) |
nullable, no constraint; carries task_scoped, which is invalid on both columns above |
the fulfillment orchestrator, for leased task-scoped instances |
So if you want ephemeral or spot machines, create an InstancePool with
that class and let it provision its members — see
instance-pool-tuning.md. The class is a property of
the pool; the Phase 1 call above builds a node that records no class at all.
What else to watch:
template_iddetermines which modules will be assigned at bootstrap. To reuse an existing fleet template, query first:platform.system_list_templates.- A
Nodewith noNodeInstanceis harmless — bookkeeping only.
platform.system_provision_instance({
node_id: "<node-id>",
provider_region_id: "region-aws-us-east-1", // or "region-local-qemu"
provider_instance_type_id: "type-t3-medium", // or "type-qemu-2cpu-4gb"
// Optional:
spot: false,
ssh_key_ids: ["<key-id>"] // injected via fw-cfg metadata for break-glass
})
// → { instance: { id, status: "provisioning", task_id, ... } }The platform creates a Task (status=pending), enqueues a worker job, and returns immediately. The worker runs the provider's provision_instance! adapter:
- AWS / GCP / Azure / OpenStack — provider-specific API calls; takes 30 s – 5 min
- LocalQemuProvider — libvirt domain creation with direct kernel boot from M3 artifacts; takes ~10-30 s in
realmode, instant inlocalmode (theRecorderRunner, perproject_local_qemu_providermemory)
Instance AASM transitions on the happy path: pending → provisioning → running
(mark_provisioning, then mark_running on the first phase=ready heartbeat).
If the provider call or boot fails, the worker drives mark_errored so the
instance lands in error rather than dangling in provisioning — there is no
orphaned-pending row left behind.
Idempotent retries. system_provision_instance accepts an operation_id.
A retried call carrying the same operation_id reuses the existing instance
row (tagged in config->>'operation_id') instead of creating a second VM —
this dedups transient-error retries. A retry without an operation_id falls
back to time-stamped naming and will create a distinct instance, so always
thread the same operation_id through when retrying a failed provision.
Verify provisioning:
platform.system_get_instance({ instance_id: "<instance-id>" })
// → { instance: { status: "provisioning", task_id, last_heartbeat_at: null, ... } }
// To watch task progress, list current tasks for the instance and read the matching row:
platform.system_list_tasks({ instance_id: "<instance-id>" })
// (or fetch a single row directly: platform.system_get_task({ id: "<task-id>" }))What to watch:
- Provider quota: most providers throttle bulk provisioning. For >10 instances, use
provision_clusterskill (hard cap 50/call) or sequence calls withsystem_create_instance_pool(slice 7) for warm capacity. MissingNetbootImageError: the platform-side disk image hasn't been published to OCI yet. Runsystem_list_disk_image_publicationsto confirm the publication exists withstatus=published.- LocalQemuProvider: ensure
POWERNODE_LIBVIRT_MODE=realandPOWERNODE_IMAGE_BASEpoints atextensions/system/initramfs/build. SeeSMOKE_TEST.md.
The provider VM POSTs to runtime/handshake once the kernel boots:
- Identity discovery — agent reads from
cmdline/virtio-fw-cfg/ cloud metadata; selects the appropriateIdentityStrategy - Enrollment — agent generates Ed25519 keypair, POSTs CSR to
/api/v1/system/node_api/enrollwith bootstrap token; receives signed mTLS cert - Module pull — agent fetches each assigned module's erofs blob from the PLATFORM's digest-addressed proxy (
/api/v1/system/node_api/files/modules/:id), not from a registry — the agent has no registry client — and verifies the sha256 digest. Cosign and fs-verity are NOT enforced on this path; seeagent/internal/verify/doc.go - Mount union root — erofs lower layer + tmpfs (or
/persist) overlay;pivot_rootinto composed userspace - Service start —
systemctl start powernode-agent.service; agent postsphase=readyheartbeat
The platform marks the instance status=running after the first phase=ready POST.
What to watch:
- Bootstrap timeline: ~90 s from kernel boot to
phase=readyon warm cache; +30-60 s on first run when modules aren't cached. Slice 7 instance pools cut this to <30 s by pre-warming. - Stuck in
bootstrapping: usually a module pull failure (signature verify, network, OCI 404). Checkjournalctl -u powernode-agenton the node, the provision task'serror_message(system_list_tasks({ instance_id })→system_get_task), or the instance's FleetEvents viasystem_recent_signals(exactkindorcorrelation_id; the verb has no per-instance filter, so readnode_instance_idoff each event). Not the introspection verbrecent_events— it reads agent execution events and never returns a FleetEvent. - Bootstrap token rotation: tokens expire 24 h after issue. Re-provision if you see
BootstrapTokenExpiredError.
The instance heartbeats every 30 s. Per-tick:
- Agent posts heartbeat (uptime, version, last reconcile result)
- Platform refreshes
last_heartbeat_at instance_status_sensorruns every 60 s; firessystem.instance_silentif no heartbeat in 3 min (InstanceStatusSensor::SILENT_THRESHOLD = 3.minutes)- Module reconciler walks assigned modules; pulls + verifies + mounts updates if module versions changed
- Task lease: agent claims any pending tasks for this instance via
node_api/status/tasks(served fromcurrent_instance.tasks) and runs them. NOTworker_api/tasks— that surface scopes throughnode.worker_id, which is NULL fleet-wide, so it resolves to the empty set and 404s for every task id.
Verify health:
platform.system_get_instance({ instance_id: "<instance-id>" })
// → { instance: {
// status: "running",
// last_heartbeat_at: "2026-05-04T13:42:01Z",
// running_module_digests: { "system-base": "sha256:abc...", ... },
// ...
// }}
platform.system_drift_report({ instance_id: "<instance-id>" })
// → { drift: false } or { drift: true, attach: [...], detach: [...], update: [...] }If drift: true, the module_drift_sensor will emit system.module_drift; Fleet Autonomy auto-runs drift_remediate (notify_and_proceed policy) on next tick.
system_drain_instance stops the instance. It is no longer the
marker-writing decoy this section used to document: IMP-f4fe1ed1ec1e routed the
MCP verb onto the same actuator as the platform_resilience skill's
drain_instance action, so there is now ONE drain behaviour reachable through
two doors instead of two doors that disagreed.
platform.system_drain_instance({
instance_id: "<instance-id>" // the only parameter
})
// → { drained: true, instance_id, instance_name, cordoned, cordon_state,
// stopped: true, status: "stopped", recommendations: [...] }What the call does, in full
(server/app/services/ai/tools/system_fleet_tool.rb#drain_instance →
server/app/services/system/ai/skills/platform_resilience_executor.rb#drain_instance):
- Scopes the instance to the caller's account and re-checks
system.instances.control— the grantsystem_stop_instancerequires, not the coarser one the skill's own MCP entry point maps to. - Refuses outright if the instance runs on this control plane's own hosting node (INV-1: no self-management).
- Cordons a ready pool member:
pool_state = "draining", whichInstancePoolService#acquire!reads, so the allocator stops handing it out. A claimed member is deliberately left alone — it is already un-acquirable, and both release paths guard onpool_state == "claimed", so flipping it would make it permanently unreturnable. A non-pool instance has no allocator to cordon against. A cordon write that RAISES aborts the drain before the stop: a stopped VM the allocator still calls ready is worse than a refused drain. - Stops the instance through
System::InstanceControlService, the single lifecycle choke point — so an operator ops hold still applies, the AASM transition is driven, and the provider adapter (orshutdown -h nowover SSH for a physical node) is dispatched. - Emits
platform.resilience.drain_started(orplatform.resilience.drain_failed), readable back throughsystem_recent_signals.
A refused or failed cordon/stop comes back as an ERROR, not as
drained: true. Disk and the registry row are retained —
system_start_instance brings it back.
What it still does not do. Nothing relocates in-flight work first. No Kubernetes node is cordoned or drained, no container is stopped individually, no VIP is failed over. The stop is a whole-VM stop; re-check anything that was mid-flight.
Parameters (system_fleet_tool.rb, system_drain_instance definition):
| Key | Status | Effect |
|---|---|---|
instance_id |
required | The NodeInstance to cordon and stop. |
timeout_seconds |
removed | Accepted until IMP-f4fe1ed1ec1e, stored in config and the event payload, and enforcing nothing: no timer ran, nothing auto-terminated, nothing read it back. The skill dropped it with the markers in APO-3b; the MCP verb kept advertising and forwarding it into an executor that ignored it. It is gone from both — the stop path has no knob to map it onto. |
cordon_only |
does not exist | Earlier revisions of this runbook showed cordon_only: false with the comment "false → also stop services after cordon". There has never been such a parameter on this verb. BaseTool#validate_params! (server/app/services/ai/tools/base_tool.rb) only checks that required keys are present — it does not reject extra ones — so the key was accepted, ignored and dropped, and the call returned success having done nothing extra. The capability it implied is now its own pair of verbs, system_cordon_instance / system_uncordon_instance (IMP-0467eee9fc57, below) — deliberately NOT a flag on the drain: a boolean that turns a stop into a not-stop is exactly the kind of parameter that gets dropped silently. |
The drain_* config markers are gone. drain_initiated_at,
drain_timeout_seconds and drain_initiated_by_user_id were written by the MCP
verb and read by nothing, across server/, extensions/, worker/,
frontend/ and the Go agent. No writer remains. If you have instances carrying
them from before this change, they are inert history on the config blob.
A cordon marks a NodeInstance unschedulable and leaves it up: "stop
scheduling new work here, leave what is running alone" — the Kubernetes
kubectl cordon / kubectl uncordon shape at the platform layer, and the
reversible half of the drain above. It is the step to take BEFORE relocating
workloads, so the pool does not hand the node a new consumer while you work.
platform.system_cordon_instance({
instance_id: "<instance-id>",
reason: "kernel patch window" // required — recorded on the instance and the event
})
// → { instance_id, instance_name, cordoned: true, cordon_state: "fenced",
// cordon: { cordoned_at, by_user_id, reason, pool_state_before },
// pool_state: "draining", status: "running", message }
platform.system_uncordon_instance({
instance_id: "<instance-id>"
})
// → { instance_id, instance_name, cordoned: false, cordon_state: "restored",
// cordon: null, pool_state: "ready", status: "running", message }What a cordon does
(server/app/services/ai/tools/system_fleet_tool.rb#cordon_instance →
server/app/services/system/instance_cordon_service.rb):
- Writes the marker:
config["cordon"]= who, why, when, and thepool_state_beforean uncordon restores.system_get_instancereturns it ascordon(nullwhen not cordoned). A marker alone binds nobody — thedrain_*markers this verb replaces had exactly that defect — which is why step 2 exists. - Fences a ready pool member:
pool_state = "draining", the one stateInstancePoolService#acquire!never picks andrecycle_stale_members!never touches. Every scheduler that hands a NodeInstance new work goes through that oneacquire!query —AgentFleetMissionService, the CI runner lease (CiRunnerLeaseService), andsystem_acquire_pooled_instance— so fencing thepool_statefences all of them. The write is conditional (WHERE pool_state = 'ready'), so a member the allocator claimed a millisecond earlier is recorded asclaimed, not clobbered. - Emits
system.instance.cordoned(severitylow), readable throughsystem_recent_signals.
cordon_state in the result tells you what actually happened:
cordon_state |
Meaning |
|---|---|
fenced |
Ready pool member flipped to draining. The allocator will not hand it out. The replenisher counts it as gone and will provision a replacement up to target_size/max_size; on uncordon the pool is over target, and nothing trims it back on purpose — recycle_stale_members! reaps a ready member once its pool_warming_started_at passes ready_ttl_seconds, irrespective of target_size, so the surplus drains by TTL expiry rather than by a target-aware trim (and the uncordoned member, whose anchor was just reset, is the LAST one it reaches). |
claimed |
Pool member a consumer currently holds. Marker only — it is already un-acquirable, and both release paths guard on pool_state == "claimed", so flipping it would strand it. When the consumer returns it, the default recycled disposition terminates it; on a pool opted into reuse_without_reset, InstancePoolService#release! reads the marker and fences the member instead of re-admitting it (pool_state=draining, release disposition cordoned, restore target ready) — system_uncordon_instance then hands it back as ready (IMP-c9adb5a71dca). |
already_fenced |
pool_state was already draining — a drain or recycle is in flight. Marker only; an uncordon will not restore it to ready. |
not_pooled |
Not a pool member. Marker only — there is no allocator to fence. On a deployment replica the marker is honoured by System::Platform::ReplicaReconciler (IMP-c9adb5a71dca): the cordoned replica is left out of the live count (#live_scope), so target_replicas reconciliation provisions its replacement, and it is the first scale-in victim (#scale_in: cordoned first, then newest-first among the rest). A cordon alone never triggers a scale-in — the excess is measured on the un-cordoned count — and a pass that spends its budget on cordoned victims converges the rest on the next pass (its message says so). The Scaling panel reads the same number: its actual_replicas (deployments_controller#compute_actual_replicas) is the reconciler's live count — active.not_cordoned, one scope pair owned by System::InstanceCordonService — and the cordoned rows are disclosed beside it as cordoned_count, rendered as a cordoned badge (IMP-3d4058389afa). So once a replacement is provisioned the panel reads target_replicas live plus the badge for as long as the cordon stands; uncordon or terminate the cordoned replica to clear the badge. On a physical node, a dev cell or ops-hub that no deployment owns, nothing schedules through the platform, so the marker is attribution only; the node_api task lease does not read it (recorded on the task). |
Refused outright (an inline error, nothing written, and — because the gate
asks InstanceCordonService.cordon_refusal first — no approval parked): a
warming member — it is not acquirable yet, and a cordon is not placed ahead
of a member's admission (should a marker reach a warming member by another
route, NodeInstance#mark_pool_ready! honours it: the heartbeat promotion
lands on draining, never ready); an errored member (already on its way
out); a terminated instance; an instance that is already cordoned; a blank
reason.
What an uncordon does. Clears the marker and, for a member THIS cordon
fenced (pool_state_before: "ready") that is still draining and
running, hands it back as ready with a fresh ready-TTL anchor (mirroring
the reuse path in InstancePoolService#release!, so a member cordoned longer
than ready_ttl_seconds is not stale-recycled on the next tick). It fails
closed on a fenced member that is not running — a ready member that is
stopped would be handed to its next consumer as a dead VM; start it, then
uncordon. It never writes ready over a member that left draining while
cordoned (recycled, errored): it clears the marker and reports
cordon_state: "cleared". Emits system.instance.uncordoned.
Both directions are approval-gated under ONE category,
system.instance_cordon (require_approval by default — declared in
System::Governance::PolicyDeclarations::INSTANCE_CORDON_OPERATOR_POLICIES,
written by PolicyReconciler on every boot, the first one included;
tunable in the Autonomy
modal's node-lifecycle section). Where policy requires approval the call
returns { pending: true, deferred_operation_id } and nothing is written
until an operator approves. The uncordon is gated on purpose: an agent
re-admitting a node an operator cordoned for maintenance is the ops-hold
lesson — a hold that lifts itself part-way through is worse than no hold. Both
require system.instances.control, the grant system_stop_instance and the
drain require.
What a cordon is not. It is not an ops hold (system_instance_hold
refuses start/reboot/terminate at System::InstanceControlService —
though deliberately NOT stop, see below; a cordon refuses nothing there at
all, it blocks scheduling and leaves the whole lifecycle alone — the two
compose). It does not reach kubectl: a
Kubernetes node on the instance still needs kubectl cordon. It does not stop
anything — that is the drain.
Which verb, then. Depending on what you wanted:
- Stop new work landing on it, keep it running:
system_cordon_instance; reverse withsystem_uncordon_instance. - Cordon and stop a pool member:
system_drain_instance, or theplatform_resilienceskill'sdrain_instanceaction — the same code. After deploying IMP-8c0f0fe9a8cf you must re-rundb:seed: theAi::Skillrow'ssystem_promptis what the model actually reads, seeds never re-run automatically on an existing install, and the stale text still says the branch "cordons nothing and stops nothing" — an actively dangerous instruction now that it stops instances. - Stop the workloads on the node without touching the pool:
system_stop_instance. Same choke point, no cordon. - Keep it from being started or terminated while you work:
system_instance_holdplaces an operator ops hold. Every caller reachingSystem::InstanceControlServiceis refused forstart/reboot/terminatewhile it is set, andforcedoes not override it (server/app/services/system/instance_control_service.rb:32, 95-103);stopis deliberately still allowed — which is why a drain still works against a held instance. - Cordon a Kubernetes node: do it at the Kubernetes layer.
kubectl cordon <node>, thenkubectl drain --ignore-daemonsets, using the cluster's own kubeconfig fromplatform.kubernetes_get_kubeconfig. Nothing insystem_drain_instancereaches kubectl. - Stop containers on a Docker host: use the
docker_*verbs against the DockerHost directly.
The sequence that actually retires a running instance:
system_cordon_instance— so the pool stops handing the node new consumers while you do step 1. Reversible if you change your mind; the drain below finds the member alreadydraining, reportscordon_state: "cordoned", and leaves the marker on the instance.- Relocate the workloads yourself, first. This step is manual and
unverified: drain the Kubernetes node with
kubectl, stop or migrate containers, move the VIP. Nothing on the platform side reports progress, because nothing on the platform side knows the relocation started — and the drain below will NOT wait for it. system_drain_instance— cordons the pool member and stops the VM. Read the result: a refusal comes back as an error, andcordon_statetells you whether the allocator was actually fenced (cordoned), whether there was nothing to fence (not_pooled), or whether the member isclaimedand must be returned withsystem_return_pooled_instance.- Confirm by your own means that the node is idle.
system_terminate_instance— which really does destroy the provider VM: it routes throughSystem::ProvisioningService#terminate_instance, which calls the provider adapter's ownterminate_instanceon the instance'scloud_instance_id(server/app/services/system/provisioning_service.rb:265-290). It is approval-gated (action_category: "system.task.terminate",system_fleet_tool.rb:426-435): where policy requires approval the call returns{ pending: true }with anapproval_request_idand the instance is not terminated until an operator approves (:766-770). Do not read that response as a completed termination.
The ordering changed with the behaviour. While the drain only wrote a timestamp, it was safe to call first and relocate afterwards. It now STOPS the instance, so relocation has to come first — a drain issued before the workload moves takes the workload down with the VM.
What to watch when you do step 1 by hand (these are properties of kubectl drain and of your cluster, not of system_drain_instance):
- Pod relocation needs capacity on the remaining nodes — a drain stalls if the cluster is at capacity. Add capacity first, or accept a partial drain.
- Local-path PVCs do not migrate; pods using them go pending. Plan stateful placement accordingly.
- A single-server K3s cluster cannot drain its only server — kubectl loses
access. Add a second
k3s-serverfirst, or accept the outage and hard-terminate.
platform.system_terminate_instance({ instance_id: "<instance-id>" })
// → { instance_id, status: "terminated" }
// (single-step AASM transition running/stopped/error → terminated; there is
// no intermediate "terminating" instance state)Cascade actions (FK + service-level):
- Provider VM destroyed via the same provider adapter that created it
Devops::DockerHost(if managed) destroyed; Vault TLS material revokedDevops::KubernetesNode(if k3s-*) destroyedSdwan::Peerrows for this instance removed (slice 9 cleanup callback)Sdwan::VirtualIpfailover triggered if this instance was a holder (slice 3)NodeCertificaterows revokedBootstrapTokenrows expired- Proxmox ISO-transport seed (
cidata-<vmid>-<key>.iso) deleted by the terminate itself
Orphaned Proxmox seed ISOs. Run rake system:proxmox:reap_seed_isos (dry run) after the first week, then APPLY=1 to delete what it lists. It keeps any file matching a live instance's current key (any account) and anything newer than GRACE_HOURS (default 6).
The Node row remains by design — re-provisioning into the same logical Node preserves history and audit chain. Delete the Node explicitly via system_delete_node only if it's truly retired.
Stuck states map to the 9 real AASM states. "bootstrapping" is the agent
boot window inside provisioning; drain/terminate are operations (the instance
moves running → stopping → stopped or → terminated), not states.
| Stuck in… | Likely cause | Recovery |
|---|---|---|
pending (>5 min) |
Worker queue stalled or provider quota | Read the provision task's error_message (system_list_tasks({ instance_id }) → system_get_task) — a provider quota refusal lands there; no provider_quota_exceeded FleetEvent kind is emitted. Restart worker via sudo systemctl restart 'powernode-*-sidekiq.service'; retry with the same operation_id |
provisioning (>10 min) |
Provider API timeout, libvirt domain creation hung | platform.system_cancel_task the provision task; investigate provider. terminate does not fire from provisioning today — see "Clearing a stuck provisioning instance" below |
provisioning, agent up but never running (>5 min after first heartbeat) |
Module pull failure | SSH to node (if SDWAN attached) → journalctl -u powernode-agent shows the failed module + reason; common: cosign signature mismatch, OCI 404, network |
running but no heartbeats >3 min |
Network partition or agent crash | platform.system_recent_signals({ kind: "system.instance_silent", limit: 50 }) (or system_get_silent_instances() for the live list); SSH or console-access via libvirt; manual restart of powernode-agent.service |
error (terminal) |
Provider/boot failure drove mark_errored |
Read the failing task's error_message via system_get_task, then system_attribute_failure({ instance_id }) to rank recent module changes; once the cause is understood, system_terminate_instance (allowed from error) to release provider resources, then re-provision with a fresh operation_id |
| Drain stalled (>30 min) | Pods can't reschedule (capacity) | Add capacity, or hard-terminate via system_terminate_instance |
| Terminate stalled (>5 min) | Provider VM teardown stuck | Check provider console; in the worst case system_cancel_task the teardown task and clean orphan rows manually |
Because the terminate event only fires from running, stopped, or error,
an instance wedged in provisioning cannot be terminated directly. Recovery
path:
system_cancel_taskthe provision task so the worker stops retrying.- Reconcile real provider state — for all cloud providers (AWS, GCP, Azure,
OpenStack) and LocalQEMU/Proxmox, the provider's
sync_statusreconciles in-flight state against the provider API; a provider that reports the VM as gone maps toterminated, which stops the controller's in-flight wait loop. - If the VM genuinely failed to come up, the worker's
mark_erroredlands the instance inerror; from theresystem_terminate_instancereleases any provider resources and the row is cleaned up cascade-style. - Re-provision with the same
operation_idto reuse the row, or a fresh one to start clean.
A proposed remediation (#7) would let
terminatefire directly fromprovisioning/startingto simplify this path; it is not shipped yet, so use the cancel→sync→error→terminate sequence above today.
system.cloud_sync_terminated_guest_present means a row reads terminated but
the provider still lists its guest. That guest is running and billing. It
happens when InstanceControlService committed terminate! and the process
died before the provider received the destroy. The signal comes from the
check event that the hourly cloud sync (System::CloudSyncService) writes on
each successful region sync. It has no applier and stands until a person acts.
- In the provider console, confirm that the listed guest really is the
terminated instance. Match its name and creation time as well as the
cloud_instance_id, because a recycled id must not be destroyed by mistake. - If it is the same guest, destroy it through the provider's console or
CLI. No platform verb retries it: the row is already
terminated, andterminatehas no transition out of that state. If the guest is already gone, the next successful sync stops naming it.
system.cloud_sync_check_stale means a region this account has synced before,
and that is still enabled, has had no fresh check event for longer than the
sensor's lookback (3 hours). The terminated-guest check above only fires from
a fresh check, so a region in this state reports nothing wrong because nothing
is looking. Find out why the sync stopped succeeding: the worker's
SystemCloudSyncJob not running, or the provider connection or API refusing
it. Resolve each lane on its own. A fresh check clears this signal but says
nothing about a guest the presence lane named, and the reverse is also true.
For all stuck states, use the attribute_failure skill (bound to the System Concierge) to enumerate recent module/version changes that may have caused the failure. The skill is invoked through the System Concierge chat agent (operator describes the failure; Concierge calls the executor internally and returns the analysis):
// Find the Concierge agent and invoke it with a natural-language ask:
platform.list_agents({ name_contains: "Concierge" })
// → { agents: [{ id: "<concierge-uuid>", name: "System Concierge", ... }] }
platform.execute_agent({
agent_id: "<concierge-uuid>", // execute_agent also accepts a slug or exact name
input: { input: "Attribute the recent failure on instance <instance-id> looking back 24 hours; surface the top candidate module/version change and confidence." }
})
// The Concierge calls the system-attribute-failure executor internally and returns:
// → { candidates: [...], top_candidate: {...}, confidence: "medium", reasoning: "..." }There is no direct execute_skill MCP action — skills are executor-shape, invoked by their owning agent. Operators interact with skills by talking to the agent that binds them.
For offline development or CI smoke tests, use the LocalQemuProvider:
# Prerequisites: libvirt, dracut, qemu-bridge-helper, Go toolchain
# Build M3 artifacts first:
cd extensions/system/initramfs && ./build.sh
# Run the smoke seed (provisions one NodeInstance to multi-user.target):
cd server && \
POWERNODE_LIBVIRT_MODE=real \
POWERNODE_IMAGE_BASE=../extensions/system/initramfs/build \
bundle exec rails runner \
"load Rails.root.join('../extensions/system/server/db/seeds/smoke_test_provision.rb')"The seed creates: 1 Account → 1 Node (no lifecycle_class — the column was dropped) → 1 NodeInstance via LocalQemuProvider, watches the AASM Task progression, and reports the kernel boot pipeline through to multi-user.target. Total runtime: ~15 min on cold boot (TCG without /dev/kvm); ~3 min with KVM.
LocalQemuProvider modes (the POWERNODE_LIBVIRT_MODE value → runner class):
real—LibvirtRunner: actual libvirt domain creation + QEMU/KVM boot (default for smoke)local—RecorderRunner: records what the libvirt adapter would do (fast; no VM). Default outside productiondisabled—DisabledRunner: skips provider entirely; useful for unit tests
Switch via POWERNODE_LIBVIRT_MODE=real|local|disabled (an unrecognized value raises Unknown POWERNODE_LIBVIRT_MODE=...).
When an operator chats "I want to add a node" / "provision a new instance" / "decommission edge-tokyo-01":
- Identify the requested phase (create / provision / drain / decommission)
- Surface the relevant MCP action(s) + required inputs (template, region, instance type)
- For destructive actions (drain, terminate), use
request_confirmationskill before invoking - After invoking, watch the Task AASM transitions and report status changes back to the operator
- If status hangs, surface the "Per-state error recovery" guidance for the relevant stuck state
The Concierge has 4 read-shape skills useful here: capacity_recommend (for "do I need more nodes?"), attribute_failure (for "why did instance X fail?"), runbook_generate (for template-specific runbooks), cve_runbook_generate (when provisioning is blocked by a CVE).
USE_CASE_MATRIX.md— 10 NodeInstance use cases with status badgesCONTAINER_RUNTIMES.md— Phase 1 Docker + Phase 2 K3s lifecycle (depends on this runbook for instance provisioning)runbooks/sdwan-network-setup.md— attach SDWAN peer (required for managed runtimes)runbooks/instance-pool-tuning.md— pre-warmed pools (slice 7) for ephemeral workloadsSMOKE_TEST.md— LocalQemuProvider smoke test setupdb/seeds/smoke_test_provision.rb— canonical provisioning seed
Last verified: 2026-06-03