Skip to content

Commit 1263325

Browse files
committed
User-Defined Node Taints on Provisioned Nodes
Signed-off-by: Erick Bourgeois <erick@jeb.ca>
1 parent c6c226c commit 1263325

20 files changed

Lines changed: 3252 additions & 3 deletions

.claude/CHANGELOG.md

Lines changed: 222 additions & 0 deletions
Large diffs are not rendered by default.

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ CALM_DIAGRAMS_OUT := docs/src/architecture
1313
REGISTRY ?= ghcr.io
1414
IMAGE_NAME ?= 5spot
1515
IMAGE_TAG ?= latest-dev
16-
NAMESPACE ?= 5-spot-system
16+
NAMESPACE ?= 5spot-system
1717

1818
# Platform configuration for builds
1919
# Default is linux/amd64 (most common for Kubernetes deployments)

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ A cloud-native Kubernetes controller for managing time-based machine scheduling
5353
- 🔄 **Graceful shutdown** - Configurable grace periods with automatic node draining
5454
- 🎯 **Priority-based** - Resource distribution across controller instances
5555
- 🚨 **Kill switch** - Emergency immediate removal capability
56+
- 🏷️ **User-defined Node taints** - Declare taints in `spec.nodeTaints`; the controller applies them once the Node is Ready, tracks ownership, and reconciles drift
5657
- 📊 **Multi-instance** - Horizontal scaling with consistent hashing
5758
- 🔍 **Full observability** - Prometheus metrics and health checks
5859

deploy/admission/validatingadmissionpolicy.yaml

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,82 @@ spec:
148148
- expression: "object.spec.infrastructureSpec.kind.size() > 0"
149149
message: "spec.infrastructureSpec.kind must not be empty"
150150
reason: Invalid
151+
152+
# ── 14. nodeTaints: key format (RFC-1123 qualified name) ──────────────────
153+
# Mirrors validate_taint_key() in src/crd.rs. The optional "<prefix>/" part
154+
# is a DNS subdomain; the name part is a qualified name. Both capped at 63
155+
# chars post-split. Core/v1 admission applies the same regex — we duplicate
156+
# it here so bad input never reaches the reconciler.
157+
# Trivially true when nodeTaints is absent or empty.
158+
- expression: |
159+
!has(object.spec.nodeTaints) ||
160+
object.spec.nodeTaints.all(t,
161+
t.key.matches('^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$'))
162+
message: >-
163+
spec.nodeTaints[*].key must be an RFC-1123 qualified name
164+
(optional DNS-1123 prefix '/' followed by a label of letters/digits/'-','_','.')
165+
reason: Invalid
166+
167+
# ── 15. nodeTaints: key length ≤ 253 total, name-part ≤ 63 ────────────────
168+
# Qualified-name rule: the name after the optional '/' is ≤ 63 chars.
169+
# We check total length ≤ 253 which also bounds the name part since the
170+
# prefix is ≤ 253 per DNS-1123. A tighter per-part cap would require CEL
171+
# string splits that are verbose; the Rust-side validator does enforce
172+
# the 63-char name-part cap as the authoritative guard.
173+
- expression: |
174+
!has(object.spec.nodeTaints) ||
175+
object.spec.nodeTaints.all(t, t.key.size() <= 253)
176+
message: "spec.nodeTaints[*].key must be ≤ 253 characters"
177+
reason: Invalid
178+
179+
# ── 16. nodeTaints: value length ≤ 63 ─────────────────────────────────────
180+
# Mirrors validate_taint_value() in src/crd.rs. Core/v1 enforces the same
181+
# 63-char cap on taint values.
182+
- expression: |
183+
!has(object.spec.nodeTaints) ||
184+
object.spec.nodeTaints.all(t, !has(t.value) || t.value.size() <= 63)
185+
message: "spec.nodeTaints[*].value must be ≤ 63 characters"
186+
reason: Invalid
187+
188+
# ── 17. nodeTaints: reserved 5spot.finos.org/ prefix ──────────────────────
189+
# Our own controller owns this namespace for internal taints / annotations.
190+
# Users must not set taints here via the CR; request routing for reserved
191+
# keys is handled server-side.
192+
- expression: |
193+
!has(object.spec.nodeTaints) ||
194+
object.spec.nodeTaints.all(t, !t.key.startsWith('5spot.finos.org/'))
195+
message: >-
196+
spec.nodeTaints[*].key must not start with '5spot.finos.org/' —
197+
this prefix is reserved for 5Spot controller-owned taints
198+
reason: Invalid
199+
200+
# ── 18. nodeTaints: reserved kubernetes.io / node.kubernetes.io /
201+
# node-role.kubernetes.io prefixes ────────────────────────────────────────
202+
# These prefixes carry special kubelet/scheduler semantics and must not
203+
# be set via spec.nodeTaints — operators wanting control-plane role
204+
# taints should use spec.machineTemplate instead.
205+
- expression: |
206+
!has(object.spec.nodeTaints) ||
207+
object.spec.nodeTaints.all(t,
208+
!t.key.startsWith('kubernetes.io/') &&
209+
!t.key.startsWith('node.kubernetes.io/') &&
210+
!t.key.startsWith('node-role.kubernetes.io/'))
211+
message: >-
212+
spec.nodeTaints[*].key must not start with 'kubernetes.io/',
213+
'node.kubernetes.io/', or 'node-role.kubernetes.io/' —
214+
use spec.machineTemplate for control-plane role taints
215+
reason: Invalid
216+
217+
# ── 19. nodeTaints: no duplicate (key, effect) pairs ──────────────────────
218+
# Mirrors the duplicate-check arm of validate_node_taints() in src/crd.rs.
219+
# Identity is the tuple (key, effect); value is mutable. We check that
220+
# every entry appears exactly once under (key, effect). O(n²) scan —
221+
# acceptable because nodeTaints is typically ≤ 5 entries per CR.
222+
- expression: |
223+
!has(object.spec.nodeTaints) ||
224+
object.spec.nodeTaints.all(t,
225+
object.spec.nodeTaints.filter(x, x.key == t.key && x.effect == t.effect).size() == 1)
226+
message: >-
227+
spec.nodeTaints must not contain duplicate (key, effect) pairs —
228+
taint identity is the tuple (key, effect); value is mutable
229+
reason: Invalid

deploy/crds/scheduledmachine.yaml

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ spec:
9393
description: |-
9494
Optional list of process patterns that trigger an emergency node
9595
reclaim. When non-empty, the 5-Spot controller installs the
96-
`5spot-reclaim-agent` DaemonSet on every Node backing this
96+
`5spot-reclaim-agent` `DaemonSet` on every Node backing this
9797
`ScheduledMachine`; the agent watches `/proc` for any process whose
9898
basename or argv matches one of these patterns and, on first match,
9999
annotates the Node to request immediate (non-graceful) removal from
@@ -135,6 +135,49 @@ spec:
135135
default: 5m
136136
description: Timeout for draining the node before deletion (e.g., "5m", "10m")
137137
type: string
138+
nodeTaints:
139+
description: |-
140+
User-defined taints applied to the Kubernetes Node once it is Ready.
141+
142+
The controller owns and reconciles only the taints it applied (tracked
143+
in `status.appliedNodeTaints` plus the `5spot.finos.org/applied-taints`
144+
annotation on the Node). Admin-added taints on the same Node are left
145+
untouched. A taint is identified by the tuple `(key, effect)`; `value`
146+
is mutable. Keys prefixed with `5spot.finos.org/`, `kubernetes.io/`,
147+
`node.kubernetes.io/`, or `node-role.kubernetes.io/` are rejected.
148+
items:
149+
description: |-
150+
A taint the controller applies to the Kubernetes Node once it is Ready.
151+
152+
Mirrors the shape of core/v1 `Taint`. Identity is the tuple `(key, effect)`;
153+
`value` is mutable. See `ScheduledMachineSpec.node_taints` for ownership
154+
semantics.
155+
properties:
156+
effect:
157+
description: Taint effect — one of `NoSchedule`, `PreferNoSchedule`, `NoExecute`.
158+
enum:
159+
- NoSchedule
160+
- PreferNoSchedule
161+
- NoExecute
162+
type: string
163+
key:
164+
description: |-
165+
Taint key. Must be a qualified name (`[prefix/]name`), 1–63 chars on the
166+
name portion and matching `[a-z0-9A-Z]([-a-zA-Z0-9.]*[a-zA-Z0-9])?`.
167+
Reserved prefixes (`5spot.finos.org/`, `kubernetes.io/`,
168+
`node.kubernetes.io/`, `node-role.kubernetes.io/`) are rejected.
169+
type: string
170+
value:
171+
description: |-
172+
Optional taint value (max 63 chars). Matches the same qualified-name
173+
pattern as `key`'s name portion when set.
174+
nullable: true
175+
type: string
176+
required:
177+
- effect
178+
- key
179+
type: object
180+
type: array
138181
priority:
139182
default: 50
140183
description: Priority for machine scheduling (higher values = higher priority)
@@ -183,6 +226,46 @@ spec:
183226
status:
184227
nullable: true
185228
properties:
229+
appliedNodeTaints:
230+
description: |-
231+
Taints the controller has applied to the Node, in the order they were
232+
applied. Maintained as the controller's record of truth so subsequent
233+
reconciles only mutate taints we own — admin-added taints on the same
234+
Node whose `(key, effect)` collides with an entry here are surfaced as
235+
a `TaintOwnershipConflict` condition rather than overwritten.
236+
items:
237+
description: |-
238+
A taint the controller applies to the Kubernetes Node once it is Ready.
239+
240+
Mirrors the shape of core/v1 `Taint`. Identity is the tuple `(key, effect)`;
241+
`value` is mutable. See `ScheduledMachineSpec.node_taints` for ownership
242+
semantics.
243+
properties:
244+
effect:
245+
description: Taint effect — one of `NoSchedule`, `PreferNoSchedule`, `NoExecute`.
246+
enum:
247+
- NoSchedule
248+
- PreferNoSchedule
249+
- NoExecute
250+
type: string
251+
key:
252+
description: |-
253+
Taint key. Must be a qualified name (`[prefix/]name`), 1–63 chars on the
254+
name portion and matching `[a-z0-9A-Z]([-a-zA-Z0-9.]*[a-zA-Z0-9])?`.
255+
Reserved prefixes (`5spot.finos.org/`, `kubernetes.io/`,
256+
`node.kubernetes.io/`, `node-role.kubernetes.io/`) are rejected.
257+
type: string
258+
value:
259+
description: |-
260+
Optional taint value (max 63 chars). Matches the same qualified-name
261+
pattern as `key`'s name portion when set.
262+
nullable: true
263+
type: string
264+
required:
265+
- effect
266+
- key
267+
type: object
268+
type: array
186269
bootstrapRef:
187270
description: Reference to the created bootstrap resource
188271
nullable: true

docs/src/concepts/scheduled-machine.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,65 @@ Optional configuration applied to the created CAPI Machine.
119119
| `nodeDrainTimeout` | `string` | No | `5m` | Timeout for draining the node before deletion. |
120120
| `killSwitch` | `bool` | No | `false` | Operator-driven kill switch. Immediately remove machine if `true`; reset to `false` to return to scheduled service. |
121121
| `killIfCommands` | `[]string` | No | `null` | Node-side process-match kill switch. When non-empty, the reclaim agent DaemonSet is installed on the backing node and watches `/proc` for any process whose `comm` or `cmdline` matches an entry. First match triggers `EmergencyRemove` + auto-disables the schedule. See [Emergency Reclaim](./emergency-reclaim.md). |
122+
| `nodeTaints` | `[]NodeTaint` | No | `[]` | User-defined taints applied to the Kubernetes Node once it is Ready. The controller owns and reconciles only the taints it applied; admin-added taints on the same Node are left untouched. See [Node Taints](#node-taints) below. |
123+
124+
## Node Taints
125+
126+
`spec.nodeTaints` declares taints that must exist on the Kubernetes Node once
127+
it joins the cluster and reports `Ready=True`. The controller patches them on
128+
via server-side apply, tracks what it applied in `status.appliedNodeTaints`,
129+
and reconciles drift on every Node change (event-driven via the Node watch —
130+
no polling).
131+
132+
### Shape
133+
134+
```yaml
135+
spec:
136+
nodeTaints:
137+
- key: workload
138+
value: batch
139+
effect: NoSchedule
140+
- key: dedicated
141+
value: ml
142+
effect: NoExecute
143+
```
144+
145+
| Field | Type | Required | Description |
146+
|-------|------|----------|-------------|
147+
| `key` | `string` | Yes | RFC-1123 qualified name. Max 253 chars total; name-part ≤ 63. Reserved prefixes (`5spot.finos.org/`, `kubernetes.io/`, `node.kubernetes.io/`, `node-role.kubernetes.io/`) are rejected at admission. |
148+
| `value` | `string` | No | Optional value, ≤ 63 chars. Mutable — changing the value on an existing taint triggers an update, not an add/remove. |
149+
| `effect` | `enum` | Yes | One of `NoSchedule`, `PreferNoSchedule`, `NoExecute`. Identity is the tuple `(key, effect)`. |
150+
151+
### Ownership model
152+
153+
Taint identity is `(key, effect)`; the `value` is mutable. The controller only
154+
touches taints it previously applied (tracked in `status.appliedNodeTaints` and
155+
recorded in the annotation `5spot.finos.org/applied-taints` on the Node).
156+
157+
- **Admin-added taint with the same `(key, effect)`**: surfaces as a
158+
`TaintOwnershipConflict` condition. The controller refuses to overwrite.
159+
- **Admin-added taint with a different `(key, effect)`**: ignored — left in
160+
place on the Node across reconciles.
161+
- **Spec shrinks (taint removed)**: the controller removes only taints it
162+
previously applied. If the admin has mutated the value since we applied it,
163+
the controller refuses to remove and surfaces a conflict.
164+
165+
### Status condition: `NodeTainted`
166+
167+
| status | reason | meaning |
168+
|--------|--------|---------|
169+
| `Unknown` | `NoNodeYet` | `status.nodeRef` is populated but the Node object is not yet materialised in the API server. |
170+
| `False` | `NodeNotReady` | Node exists but `Ready != True`. Will re-reconcile on the Node watch event. |
171+
| `False` | `PatchFailed` | k8s API returned an error on the last patch. Exponential backoff applies. |
172+
| `False` | `TaintOwnershipConflict` | An admin taint collides with a declared `(key, effect)`. The controller refuses to overwrite until the spec changes. |
173+
| `True` | `Applied` | All declared taints are present on the Node. |
174+
175+
### What it does not manage
176+
177+
- **Tolerations** — a Pod-side concern. Workloads must declare tolerations
178+
themselves to schedule onto tainted Nodes.
179+
- **Node labels** — separate feature with different conflict semantics.
180+
- **Admin-added taints** — never removed or overwritten by the controller.
122181

123182
## Status Fields
124183

@@ -135,6 +194,7 @@ The status subresource contains the current state:
135194
| `infrastructureRef` | `ObjectReference` | Reference to created infrastructure resource |
136195
| `nodeRef` | `NodeRef` | Reference to the Kubernetes Node (apiVersion, kind, name, uid) once provisioned |
137196
| `providerID` | `string` | Provider-assigned machine identifier (copied from CAPI `Machine.spec.providerID`) |
197+
| `appliedNodeTaints` | `[]NodeTaint` | Taints the controller has applied to the Node. Source of truth for ownership — only entries here are eligible for removal. See [Node Taints](#node-taints). |
138198
| `lastScheduledTime` | `Time` | Last time machine was created |
139199
| `nextActivation` | `Time` | Next scheduled activation time |
140200
| `nextCleanup` | `Time` | Time when machine will be cleaned up |

docs/src/operations/troubleshooting.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,71 @@ kubectl get events --field-selector reason=EmergencyReclaimDisabledSchedule \
317317
kubectl get scheduledmachine/<name> -o jsonpath='{.metadata.generation} {.status.observedGeneration}'
318318
```
319319

320+
## Node Taints
321+
322+
### Taints not appearing on Node
323+
324+
`spec.nodeTaints` is declared on a ScheduledMachine, the machine is Active,
325+
but the Node does not have the expected taints. Walk the `NodeTainted`
326+
condition on the CR first — it tells you exactly which layer is failing.
327+
328+
```bash
329+
kubectl get scheduledmachine <name> \
330+
-o jsonpath='{.status.conditions[?(@.type=="NodeTainted")]}{"\n"}'
331+
```
332+
333+
Three failure reasons, each with its own fix:
334+
335+
**`reason=NoNodeYet` (status=Unknown)**
336+
CAPI populated `status.nodeRef` but the Node object is not yet in the API
337+
server. This is usually a few seconds after Machine creation. The Node watch
338+
will re-enqueue us automatically — no action needed. If stuck for > 1 min,
339+
check that CAPI's Machine actually materialised the Node:
340+
341+
```bash
342+
kubectl get machine <name>-machine -o jsonpath='{.status.nodeRef}{"\n"}'
343+
kubectl get nodes <node-name>
344+
```
345+
346+
**`reason=NodeNotReady` (status=False)**
347+
Node exists but `Ready != True`. Kubelet hasn't registered, networking is
348+
degraded, or CNI is failing. Look at the Node's own conditions first:
349+
350+
```bash
351+
kubectl describe node <node-name> | sed -n '/Conditions:/,/Addresses:/p'
352+
```
353+
354+
Fix the underlying Node problem; the controller will re-reconcile on the next
355+
Node `Ready` transition.
356+
357+
**`reason=TaintOwnershipConflict` (status=False)**
358+
An admin taint exists with the same `(key, effect)` tuple as a declared
359+
`spec.nodeTaints` entry. The controller refuses to overwrite admin-owned
360+
taints. Inspect the current state:
361+
362+
```bash
363+
kubectl get node <node-name> -o jsonpath='{.spec.taints}{"\n"}'
364+
kubectl get node <node-name> \
365+
-o jsonpath='{.metadata.annotations.5spot\.finos\.org/applied-taints}{"\n"}'
366+
```
367+
368+
Resolve by either removing the admin taint (`kubectl taint nodes <node> key:effect-`)
369+
or changing the `spec.nodeTaints` entry so the `(key, effect)` no longer
370+
collides. Note: the annotation `5spot.finos.org/applied-taints` lists the
371+
keys we own; any taint *not* in that list belongs to the admin.
372+
373+
**`reason=PatchFailed` (status=False)**
374+
A non-404, non-conflict API error on the Node PATCH. Check controller logs for
375+
the exact kube error (RBAC rejection, API server unreachable, etc.):
376+
377+
```bash
378+
kubectl logs -n 5spot-system -l app=5spot-controller \
379+
| grep -E "node_taint|appliedNodeTaints"
380+
```
381+
382+
The controller retries with exponential backoff on transient failures. For
383+
RBAC issues, confirm the controller ClusterRole grants `patch` on `nodes`.
384+
320385
## Error Messages
321386

322387
### "Resource not owned by this instance"

0 commit comments

Comments
 (0)