ADR 0034: Infrastructure actions are operator-executed requests recorded on a NodeMaintenance object, and shutdown is offered only by a power channel that can also power on¶
Status: Accepted Date: 2026-08-04 Issue: #1225
Context¶
The Servers surface is read-only and carries exactly one action, servers:read
at the read tier. Taking a chassis out of service and returning it is the
management capability the surface exists to deliver, because for a customer
this UI is the only management surface the hardware has and the plant engineer
does not speak kubectl. Adding those verbs raises four questions that have to
be answered together: which component may hold the privilege, what records the
intent, what a power action even is, and what ceremony each verb carries.
The privilege already exists, and it is not where the proposal assumed¶
The epic proposed that the gateway perform cordon and uncordon directly on the grounds of low blast radius, with drain and the power verbs routed through an operator. Reading the two roles inverts the argument.
The gateway holds nodes: get, list (config/rbac/gateway_role.yaml). The
physical operator already holds nodes: create, delete, get, list, patch,
update, watch and policy/evictions: create
(config/rbac/physical_operator_role.yaml). Routing an action through the
operator therefore costs no new privilege anywhere. Performing cordon in the
gateway costs a new write verb against a cluster-scoped object, in the
component with the largest attack surface.
The product already cordons, drains and deletes nodes, with no ceremony at all¶
ControllerReconciler removal (internal/controller/physical/controller_controller.go)
already does all three. It sets node.Spec.Unschedulable, calls drainNode,
and deletes the Node object. Two properties of that path matter here:
drainNodefires one eviction per pod and returns without waiting for any pod to terminate. It swallowsIsTooManyRequests, which is exactly the status the API server returns when a PodDisruptionBudget refuses the eviction.- The caller logs a drain failure and continues, with the comment "Continue
anyway — best effort drain", and then deletes the
Node.
So the capability that cluster 3 is about to wrap in ceremony is already reachable today through a delete confirmation, and it reports success while leaving PDB-protected workloads in place. Any answer here has to converge on that implementation rather than grow a second one beside it.
Kubernetes has no power verb, and in-band channels are asymmetric¶
Cordon, uncordon, drain and delete-node is the entire vocabulary the gateway can reach. Reboot and shutdown need a host-layer channel, which is the same wall #1168 hit when it omitted the VIP holder.
The decisive property is asymmetry, and it belongs to the channel rather than to any implementation. An in-cluster privileged agent can shut a machine down and can never power one back on, because the agent dies with the machine. The Talos API has the same shape, since a powered-off machine serves no API. Only an out-of-band channel such as a BMC over Redfish, a cloud provider API, or a switched PDU can do both. Shipping shutdown over a channel that cannot power on turns a remote action into a drive to the plant.
Quorum is already derived twice¶
internal/gateway/servers.go derives etcd quorum for the read surface, and
deriveEtcdQuorum in internal/controller/alarm/server_alarm_controller.go
derives it again for the ADR 0032 alarm rules. ADR 0032 recorded the
duplication as an accepted cost. A quorum guard
(#1238)
would be the third copy of one stacked-etcd assumption.
Constraints in force¶
- Action names are a product contract under ADR 0024, so a rename is a breaking configuration change and the names must be settled before the first release that carries them (#1211).
- ADR 0006 owns re-binding a unit's runtime, with a fencing certification and a typed reason.
- ADR 0033 forbids any CR that mirrors an object the cluster already reconciles.
- ADR 0004 delegates node join to the deployment layer.
- A BMC sits on the management network, which is a separate zone under the
IEC 62443 posture claimed in
docs/compliance/iec62443-4-2.mdCR 5.1.
Decision¶
The gateway holds no write privilege against a Node. Every infrastructure
action is a request the gateway authorizes, audits and records on a
cluster-scoped NodeMaintenance object, which the physical operator executes.
The power channel is a deployment-configured capability provider, and shutdown
is offered only by a provider that also advertises power-on.
1. One path, through the operator¶
All four verbs (cordon, uncordon, drain, and the power verbs) route through the maintenance object. The gateway's RBAC is unchanged by this epic, which is a checkable outcome rather than a stated intention.
Cordon joins the same path as the rest for three reasons. The operator already
holds nodes: patch, so the split buys no reduction in total privilege while
adding a node write to the internet-facing component. A gateway compromise that
can cordon every node in the cluster is a plant-wide denial of service, and
"instantly reversible" describes the operator who did it deliberately rather
than the one who has to discover it first. Cordon is also the first step of
every maintenance procedure, so making step one structurally different from the
steps after it is how a second implementation gets built.
The cost is honest and accepted: a cordon now takes a reconcile round trip and mints an object where a PATCH would have done. The object is required for the lifecycle regardless, and a maintenance abandoned after its cordon is precisely the state an operator needs to see and undo.
2. NodeMaintenance is a cluster-scoped CRD recording intent¶
It records an operator's intent and its lifecycle, and mirrors nothing, so ADR 0033's no-mirror rule permits it. It is cluster-scoped because a node is, and a maintenance on one chassis is one event even where the cluster hosts several sites.
One object per maintenance, and the intent declares how far it goes.
spec.intent is one of cordon, drain, reboot, shutdown, and the
lifecycle advances only as far as the intent requires. A cordon-only
maintenance reaches Cordoned and waits there for return to service. This
gives alarm shelving (#1236)
one scope to hang on, the quorum guard one chokepoint, and drain
(#1235) the
lifecycle that has to survive a gateway restart.
States. Requested → Cordoned → Draining → Drained → Rebooting |
PoweredOff → Returning → Complete, with Refused and Failed as terminal
states and an abandon path from any non-terminal state back through
Returning. Rebooting and PoweredOff are the two places a maintenance
parks once its power verb has been sent, and a reboot needs its own because
Drained is the phase that dispatches the verb. A reboot that stayed there
would be sent again on every wake-up. The pre-flight snapshot taken at request
time is recorded on the object, so what the operator was shown when they
decided is recoverable afterwards.
The physical operator owns it. It already holds every verb involved, it
owns Controller, Unit and node discovery, and it hosts the ADR 0032 server
alarm reconciler that shelving has to reach.
The existing drain becomes the one implementation. The controller-removal path and the maintenance path converge on it, which means the PDB-blindness and the missing wait get fixed once for both. Filed as #1242.
3. Node deletion is not an infrastructure verb¶
Removing a Node object is decommissioning. ADR 0004 delegates node join to
the deployment layer, so node departure belongs there too, and the product's
own record of un-adoption is the removal of the Controller. Deletion stays
where it is today and gains no button on the Servers surface.
4. The power channel advertises capabilities, and shutdown requires power-on¶
A capability provider is configured at the deployment layer. The UI renders only the verbs the configured provider advertises, and a deployment with no provider configured shows no power verbs at all. That is the correct default and it needs no feature flag.
The capability rule. A provider advertises reboot, shutdown and
power-on independently. A provider that does not advertise power-on may
not offer shutdown. Reboot over an in-band channel stays available, because
the machine returns by itself and the asymmetry never bites.
Amendment (2026-08-05, #1288): the rule was attached to the wrong thing.
What this section protects against is a shutdown nothing can undo: the machine is off, nobody is there, and somebody has to drive to the plant. That is a property of whether the shutdown is recoverable, and a channel that can power on is only one of the two ways to be. The other is a person at the rack who has said they are there.
The rule is therefore: a shutdown is offered only where it is recoverable,
by either route. A deployment declares the second through powerProvider.attendedRack,
and ADR 0038 § 5 makes the same
declaration per site for a whole-site outage.
Two things follow, and the second is why this amendment exists at all.
The Talos backend now advertises shutdown honestly, because it really can
perform one: Talos stops its own services, drains the kubelet and unmounts
cleanly, which is a graceful power-off rather than a cut. It still never
advertises power-on. The structural guarantee that used to live in
CapabilitySet — where a set carrying shutdown without power-on could not be
constructed — moves to power.OfferFor and power.Invoke, the two places that
know the deployment's recovery model. An interface shape could never have
expressed attendance anyway, because it is a fact about a place rather than
about a channel.
And the original rule made the product recommend the worse act. ADR 0038's attended form left the machines running so a human could remove power at the rack, which is a hard cut of a live operating system, chosen over the sequenced shutdown the product could already have asked Talos to perform. The asymmetry objection does not apply to that case, because the attended form's whole premise is that the person is already there.
This is what makes an in-band first backend safe. The Talos API is the sensible
first implementation, because it is what the bench runs, it can be proven on
our own metal, and it brings the VIP holder within reach through the same
channel. It advertises reboot and withholds shutdown. Redfish over a BMC
comes second and unlocks shutdown and power-on where the hardware has a
BMC. Cloud provider APIs are the third case, where the repairable versus
replaceable classification
(#1233)
says the useful verb is terminate.
An in-cluster privileged agent is rejected outright, because it is the one channel that offers no path to symmetry at any later date.
The provider runs in the operator, which already occupies the privileged
position, so the management-network conduit originates in one component rather
than in the gateway. A deployment with no provider opens no conduit at all,
which keeps the default posture unchanged. Provider credentials arrive as a
Secret reference supplied by the deployment layer. They are machine identities,
so engineering states the scope and blast radius and files the issue in
cndcs-operations, and that issue is part of #1237's definition of done.
5. Tier and ceremony¶
Infrastructure actions sit at PermAdmin, and configuring the power
provider does too.
This follows a founder ruling of 2026-08-04 that states a general default:
where an action needs elevated access, PermAdmin is the safe answer unless
there is a specific reason to sit lower. The reasoning is that a tier is
granted once and lived with for years, an over-granted tier is discovered only
after it is abused, and an under-granted one is discovered immediately by an
engineer who cannot do their job. The two errors are not symmetric, so the
default belongs on the side that fails loudly.
Applied here, a chassis leaving service is exactly the class of act that default reaches for. The plant-floor argument for a lower tier is real and it is answered by delegation rather than by the default.
Delegation runs through ADR 0024 in the allow direction. A plant that wants
its maintenance engineers to cordon a node without holding terminal access and
bulk export adds servers:cordon and servers:uncordon to the allow list of
whichever role those engineers hold. The per-action policy machinery works in
both directions, and starting from admin means every such grant is a deliberate,
reviewable line in the deployment's roles file. Starting from operate-lead would
have made the same access the silent default in every deployment that never
opened the file.
| Action | Ceremony |
|---|---|
servers:uncordon, return to service |
Reason |
servers:cordon |
Reason |
servers:drain |
Reason, plus acknowledgement of the pre-flight impact |
servers:reboot, servers:shutdown |
Reason, pre-flight acknowledgement, typed node name |
| Any action consuming the last of the quorum margin | Hazard tier: warning title icon, typed reason, typed node name |
| Any action that would drop ready control-plane nodes below quorum | Refused |
| Drain, reboot or shutdown of a controller node hosting live control | Routed through the ADR 0006 failover path first, with its fencing certification |
Every action carries a reason and every reason meets the deployment's reason policy, matching the #687 treatment the failover re-bind already gets.
A refusal is never upgraded into a ceremony. Below-quorum is refused outright, so clicking through a sufficiently frightening dialog never becomes the path to killing the cluster. A refusal explains itself with the member count, the quorum size and the margin.
Absent derivation refuses. A cluster whose control-plane nodes are not labelled derives no quorum, and ADR 0032 already records that it therefore raises no quorum alarm. A guard that passed because it derived nothing would be the same silence with worse consequences.
The quorum derivation collapses to one implementation. The guard does not become a third copy. The derivation moves to a shared package with three callers: the gateway read surface, the ADR 0032 reconciler, and the guard. That closes the divergence ADR 0032 accepted as a cost.
The guard is authoritative in the operator, which is the component that
executes, so no client and no direct API call can bypass it. The gateway runs
the same shared derivation at request time so the refusal is synchronous and
the operator sees the numbers immediately. A request that clears the gateway
and fails the authoritative check lands the object in Refused with the
numbers in status.
Virtual nodes refuse every action. Nodes labelled dcs.io/virtual-node are
the simulation fixtures the Controller reconciler creates, and ADR 0032 already
excludes them from alarms. The refusal is enforced in the operator rather than
by hiding buttons, because a hidden button is defeated by a direct API call and
the docs capture rig runs on exactly these nodes.
6. The action names, settled¶
Seven names in the servers: domain, which already covers controller-bound
chassis because servers:read does. Each is independently deniable under
ADR 0024.
| Action | Default tier |
|---|---|
servers:read (exists) |
PermRead |
servers:cordon |
PermAdmin |
servers:uncordon |
PermAdmin |
servers:drain |
PermAdmin |
servers:reboot |
PermAdmin |
servers:shutdown |
PermAdmin |
servers:maintenance-abandon |
PermAdmin |
Cordon and uncordon are separate names so a deployment can let a wider set of roles restore service than can withdraw it.
Amendment (2026-08-05, #1281):
the domain grew, and this table is no longer the whole contract.
ADR 0038 settles a site-scoped outage,
which is a different blast radius in a site: group of its own, and it adds an
eighth name here: servers:power-on at PermAdmin. Seven names covered
taking a machine out of service and none brought one back, so a shutdown that
reached PoweredOff had no route back through the product
(#1282). The
§ 4 capability rule governs it unchanged: a channel that cannot power on never
advertises it, which is the same channel that may not offer shutdown.
7. Audit records the human act, status records the machine's¶
The gateway mints the AuditRecord at request time, because it holds the
authenticated identity and already audits every tag write. It carries the
actor, the reason, the target node, and the name of the NodeMaintenance
object, so the trail and the object are joinable. The source is
Server/<node>, the identity ADR 0032 already uses.
The operator records execution outcomes on the object's status. These are two records of two different things: what a human asked for, which is what 21 CFR Part 11 and IEC 62443 want in the trail, and what the machine subsequently did.
Amendment (2026-08-07, #1324):
PoweredOff says what was requested, and a condition says what was observed.
A shutdown reaches PoweredOff the instant the channel accepts the request.
That is the whole of what an in-band channel can ever support, because the
machine stops serving the API it was asked through and its silence afterwards is
indistinguishable from a network fault. An out-of-band channel has its own
processor and its own power, so it keeps answering while the machine is dark and
can report that the machine reached off. Those are two different claims and a
plant record must not print the stronger one when it only holds the weaker.
The phase therefore never waits on a readback, and the PowerOffConfirmed
condition carries the stronger claim where a deployment can make it. True is an
observation, False is a readback still running, and Unknown is settled without
one — either the channel cannot see, or it never reported the machine off before
the window closed. status.poweredOffAt records when the channel accepted the
shutdown and is what bounds that window.
Who polls, and how often, is settled here too. The reading is taken for the
one machine this product just stopped, at a cadence of its own rather than the
drain's, and only until that shutdown lands or the window closes. It is never an
ambient property of the Servers list: a five-second UI poll multiplied across a
rack is a load pattern the management network did not ask for, and no surface
needs a general "off versus absent" distinction that a maintenance sitting at
PoweredOff does not already give. Consuming the reading at the moment it
changes what the product may claim is the whole of what the mechanism is for.
Two consequences. The power advertisement stopped serving its stateReadback
flag, which had been on the wire since #1237 and read by nothing: the fact is
consumed where the reading is taken, and an advertisement that promises a
capability no surface acts on is read as a promise the product keeps. And a
SiteOutage leg still completes on the phase rather than on the confirmation,
because a worker finishing its stop does not gate the next leg of a sequence.
Alternatives Considered¶
The gateway performs cordon and uncordon directly, as the epic proposed,
keeping the operator for drain and power. Rejected on the RBAC reading. The
operator already holds nodes: patch, so the split reduces no privilege while
adding a cluster-scoped write to the most exposed component, and it splits one
maintenance procedure across two implementations that the quorum guard would
then have to be written into twice.
Record the intent as an annotation on the Node, following the ADR 0006
precedent where failover is triggered by annotations on the Unit. Rejected on
three counts. It is a write to the object ADR 0033 keeps the product out of, it
has no status subresource to carry a lifecycle or a pre-flight snapshot, and
ADR 0031 already recorded that node-level state is lost on re-enrolment.
An in-cluster privileged agent for power. Cheapest to build and needs no credentials outside the cluster. Rejected because it can never power a machine on, and unlike the Talos API it has no path to symmetry later.
Redfish as the first backend. It is the symmetric channel and the eventual answer for bare metal. Rejected as the first one, because no bench hardware has a BMC, so the first backend could not be proven on our own metal, and the bench is what the product's hardware claims rest on. The capability rule keeps the asymmetry trap closed without requiring Redfish to come first.
Amendment, 2026-08-07 (#1284). Redfish is now the second backend and it is built. The ordering argument above survived, and the argument that had been made for putting a switched PDU ahead of it did not: that argument was the same "prove it on metal we own" reasoning, applied to a device we do not own either. A PDU client would have had to guess a vendor, a protocol and an authentication scheme for hardware nobody has chosen. Redfish is a published specification, so it is implementable against the spec and exercisable against an emulator, and it needs no purchase to be correct. A PDU remains the right channel for hardware with no BMC, which is most small plants, and it is a separate backend rather than a replacement.
Put infrastructure actions at PermOperateLead, the tier unit failover
already uses, on the reasoning that maintenance happens during a planned window
and is performed by maintenance engineers who should not need terminal access
and bulk export to cordon a chassis. This was the ADR's original position and
the founder overruled it on 2026-08-04, setting the general default recorded in
the Decision. The practical concern survives the ruling and is answered by an
ADR 0024 allow list, which makes the same access a deliberate line in a
deployment's roles file rather than the silent default everywhere.
Let a sufficiently severe ceremony authorize a below-quorum action. It respects the operator's judgment and covers the genuine emergency. Rejected because the failure it prevents is unrecoverable from the UI that offered it, and an emergency that truly requires it has kubectl.
One servers:power action covering reboot and shutdown. Fewer names to
maintain. Rejected because the two verbs differ in exactly the property this
ADR turns on, and a deployment must be able to allow reboot while denying
shutdown.
Consequences¶
- The gateway gains no write against a
Node. Itsnodesrule stays atgetandlist, and any future patch adding a node write toconfig/rbac/gateway_role.yamlis a signal that something has left this decision.
Correction, 2026-08-05. This bullet originally claimed the gateway's RBAC
was unchanged by the whole of cluster 3 and called that a checkable outcome.
It was checkable and it was false. The gateway role enumerates
physical.dcs.io resources explicitly with no wildcard, so it could not
create the NodeMaintenance object this ADR has it author, and every cordon
would have been refused in a real deployment. #1234 added
nodemaintenances at create, get, list, update. The decision is unaffected,
because that is a write against the product's own object rather than against
a Node, which is the privilege this ADR withholds.
- The existing drainNode becomes load-bearing, and its two defects have
to be fixed before the maintenance path uses it. It swallows PDB refusals and
never waits for pods to leave, and the removal path deletes the node anyway.
Filed as #1242.
- The quorum derivation improves rather than degrading. Collapsing the
gateway copy and the ADR 0032 copy into one shared package with three callers
closes a divergence that ADR 0032 accepted.
- #1236 gets its shelving scope, since the maintenance object carries the
window, and shelving expires with the object instead of depending on a clean
return path.
- #1237 gets a first backend it can prove. Talos over the bench, advertising
reboot and withholding shutdown, with Redfish adding the symmetric verbs.
Amendment, 2026-08-07. Talos no longer withholds shutdown, because #1288
moved the rule from the channel to the recovery. Redfish landed in #1284 and
is the first channel that advertises power-on, so a shutdown over it is
recoverable without anybody at the rack. servers:power-on still has no
request path; the channel can perform it and nothing in the product asks yet.
- A cordon is now a recorded event with a reason. Plants that treat
cordoning as a throwaway act will find this heavier than kubectl, which is
the intended trade in a regulated environment.
- The compliance surface moves once. docs/compliance/iec62443-4-2.md
CR 5.1 gains the management-network conduit the power provider opens, stated
as originating in the operator and absent by default. The Part 11 trail gains
infrastructure actions.
- A deployment that wants maintenance engineers to cordon must write a policy
line. That is the intended cost of the admin default: the access exists and
it is visible in the roles file rather than implied by a tier.
- Reversibility. The tier assignments and the ceremony ladder are cheap to
change before release. The seven action names are a product contract the
moment a release carries them, and the NodeMaintenance schema is a
migration once a deployment holds objects.
Related Documentation¶
- ADR index
- ADR 0004: Node join is delegated to the deployment layer
- ADR 0006: Edge-runtime failover is hold-then-resume
- ADR 0024: Action-level authorization policies
- ADR 0032: Server degradation annunciates per site
- ADR 0033: The asset tree is a site-scoped Infrastructure branch
- Security Operations
- IEC 62443-4-2 compliance