Skip to content

Commit 1197f6a

Browse files
committed
feat(fault-quarantine): gate events for opted-out nodes via skipNodeLabels (NKX-13877)
1 parent 362e7ad commit 1197f6a

8 files changed

Lines changed: 575 additions & 15 deletions

File tree

distros/kubernetes/nvsentinel/charts/fault-quarantine/templates/configmap.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,9 @@ data:
7070
[rule-sets.cordon]
7171
shouldCordon = {{ .cordon.shouldCordon }}
7272
{{- end }}
73+
74+
{{- range .Values.skipNodeLabels }}
75+
[[skip-node-labels]]
76+
key = {{ .key | quote }}
77+
value = {{ .value | quote }}
78+
{{- end }}

distros/kubernetes/nvsentinel/charts/fault-quarantine/values.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ circuitBreaker:
6767
# Example: "5m" means if 50% of GPU nodes are cordoned within any 5-minute window, the circuit breaker trips
6868
duration: "5m"
6969

70+
# Labels that cause fault-quarantine to silently drop events for matching nodes.
71+
# Any node carrying a label whose key and value match an entry here will not be
72+
# quarantined, uncordoned, or otherwise acted upon. This covers both healthy and
73+
# unhealthy events, preventing opted-out nodes from being affected by FQ.
74+
skipNodeLabels:
75+
- key: "nvsentinel.dgxc.nvidia.com/managed"
76+
value: "false"
77+
7078
# Rule sets for node quarantine actions
7179
# Each ruleset defines conditions (match) and actions (taint, label, cordon) to apply when conditions are met
7280
# Rules are evaluated using CEL (Common Expression Language) expressions

docs/designs/040-external-remediation-request.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -302,11 +302,13 @@ When `node-labeler` removes those detection labels in response to `managed="fals
302302

303303
`metadata-collector` is included in the list above for completeness: it gates on `driver.installed` AND `nvidia.com/gpu.present`. When `driver.installed` is withheld, the AND fails and `metadata-collector` is evicted too. This is the desired behaviour during external remediation — NVSentinel's collection should also stop on a released node.
304304

305-
#### Cluster-scope monitors: code-level emission gating
305+
#### Cluster-scope monitors: fault-quarantine skip-node-labels gate
306306

307-
Cluster-scope monitors (`csp-health-monitor`, `kubernetes-object-monitor`, `slurm-drain-monitor`) run as `Deployment`s, not DaemonSets, and target nodes by name from outside the node. They cannot be evicted from a node because they do not run on it. Instead, each cluster-scope monitor reads the target node's `managed` label from its Kubernetes informer cache and skips emission when `managed="false"`. Any other state (label absent, label set to `"true"`, or any other value) means the cluster-scope monitor emits as usual.
307+
Cluster-scope monitors (`csp-health-monitor`, `kubernetes-object-monitor`, `slurm-drain-monitor`) run as `Deployment`s, not DaemonSets, and target nodes by name from outside the node. They cannot be evicted from a node because they do not run on it. Unlike DaemonSet monitors, gating these monitors at the emission level would require either a node informer per DaemonSet pod (wasteful at scale) or a stale cache that introduces unacceptable latency for opt-out decisions.
308308

309-
A shared helper in `commons/pkg/` provides the lookup so the check is centralized; each cluster-scope monitor calls into it before emitting events for a given node. The check is part of the emission code path, not the scrape/poll loop — monitors keep observing, they just refuse to emit for nodes that are explicitly opted out.
309+
Instead, the gate is applied centrally in `fault-quarantine` via a configurable `skipNodeLabels` list. Before processing any event (healthy or unhealthy), fault-quarantine looks up the target node in its existing node informer cache and checks whether it carries any label from the configured skip list. If it does, the event is silently dropped: no quarantine, no uncordon, no annotation changes. This covers both new quarantine attempts (unhealthy events) and quarantine-clearing (healthy events for already-quarantined nodes).
310+
311+
The default configuration ships with `nvsentinel.dgxc.nvidia.com/managed=false` in the skip list. Operators can add additional labels via Helm values without code changes. Cluster-scope monitors continue to observe and emit events as usual; the events reach the database and are available for audit, but fault-quarantine refuses to act on them for opted-out nodes. This is consistent with the principle that events should be "observed, recorded, and exported as usual" and the guard applies only to state-changing actions.
310312

311313
#### ERR reconciler interaction
312314

@@ -324,7 +326,7 @@ Two timing components contribute to the gap between `managed="false"` being appl
324326
1. `node-labeler` observes the `managed` write via its informer and runs its own reconcile to remove detection labels. Typical latency: well under a second.
325327
2. The DaemonSet controller re-evaluates `nodeSelector` matches once detection labels are removed and begins evicting pods. Actual pod termination respects each pod's `terminationGracePeriodSeconds` — typically a few seconds.
326328

327-
The ERR reconciler does **NOT** wait for either component to complete before setting `NVSentinelOwnershipReleased=True`; the condition flips as soon as the PATCH lands. Monitor pods may emit a few last health events for the released node during the eviction window. Those events are caught downstream by the release-taint guard and do not trigger NVSentinel action; they may, however, appear briefly in the event store and observability surfaces. The release taint itself takes effect immediately on patch landing, so destructive scheduling decisions are blocked from the moment the patch is acknowledged. Cluster-scope monitors stop emitting on the next informer-cache observation of the `managed` write — effectively immediately; no pod eviction is involved, so no analogous window exists.
329+
The ERR reconciler does **NOT** wait for either component to complete before setting `NVSentinelOwnershipReleased=True`; the condition flips as soon as the PATCH lands. Monitor pods may emit a few last health events for the released node during the eviction window. Those events are caught downstream by `fault-quarantine`'s `skipNodeLabels` gate and do not trigger state-changing action; they may, however, appear briefly in the event store and observability surfaces. The release taint itself takes effect immediately on patch landing, so destructive scheduling decisions are blocked from the moment the patch is acknowledged. Cluster-scope monitors continue to emit events as usual (they are not evicted because they run as Deployments), but `fault-quarantine` checks the configured `skipNodeLabels` and silently drops matching events before any quarantine, uncordon, or annotation changes.
328330

329331
#### Trade-offs
330332

@@ -481,7 +483,7 @@ Ownership is transferred back to NVSentinel by exactly two events: (1) the exter
481483
- **External system stops progressing (never sets `ExternalRemediationComplete`, or sets `ExternalRemediationComplete=False`).** No timeout. NVSentinel intentionally does not return the node to service on its own in either case — when the external system has stopped, NVSentinel has no signal about what state the node was left in (mid-RMA, partial repair, hardware swapped but not validated, …). Returning the node to user workloads on that signal would be unsafe. The ERR therefore stays live, the release taint stays applied, `managed` stays `"false"`, and the node remains released to the external system, which may resume work and patch `ExternalRemediationComplete=True` if they end up fixing it. From NVSentinel's side, `err_open{state="awaiting"}` (Unknown) and `err_open{state="failed"}` (False) make the situation visible to operators. Operators alert on persistence (an ERR open longer than the expected external-remediation SLO) and use `kubectl delete err <name>` — backed by the finalizer-driven cleanup path (see *Operator-driven release*) — to force the node back into NVSentinel ownership when they have separately confirmed the node is safe to return.
482484
- **Node deleted while ERR is open.** ERR reconciler logs and treats taint and label operations as no-ops. The ERR object remains so the external system can still acknowledge completion (which is then a no-op against the missing node). Operators can also reclaim the ERR object itself via `kubectl delete err <name>`; the finalizer-driven cleanup path runs cleanly even with the node already gone (the cleanup PATCH no-ops, the finalizer is removed, and the ERR is garbage-collected).
483485
- **Multiple distinct faults on a node arriving while an ERR is in flight.** Primary defense is monitor teardown: with `managed=false` on the node, NVSentinel's own monitors aren't emitting events for it. The equivalence-group skip at `fault-remediation` is a defense-in-depth backstop for any event that does slip through (a final emission during the eviction window, or an event from outside the standard pipeline). Either way, only the first event's `HealthEvent` is captured in the ERR spec. Once the ERR is closed — either by `ExternalRemediationComplete=True` driven cleanup or by operator-initiated `kubectl delete err` — the release taint is removed, the `managed` label is removed, node-labeler restamps the detection labels, and monitor pods resume. Any persistent faults are then re-detected and produce a fresh ERR through the standard pipeline.
484-
- **Health event (healthy or unhealthy) arrives at fault-quarantine while an ERR is in flight.** Defense in depth: with monitor teardown in place, this should be rare (most events from NVSentinel's own monitors are stopped at the source). If one does arrive, fault-quarantine's release-taint guard refuses to act on the release-tainted node, regardless of event polarity. The event is stored as a historical record but causes no state change. The ERR remains the only authority for transitioning the node out of "released" state. After the release taint is removed and monitors resume, the next observation drives fault-quarantine's state transition normally.
486+
- **Health event (healthy or unhealthy) arrives at fault-quarantine while an ERR is in flight.** Defense in depth: with monitor teardown in place, this should be rare (most events from NVSentinel's own monitors are stopped at the source). If one does arrive, fault-quarantine's `skipNodeLabels` gate checks the node's labels against the configured skip list (which includes `nvsentinel.dgxc.nvidia.com/managed=false` by default) and silently drops the event before any processing. The event is stored as a historical record in the database but causes no state change (no quarantine, no uncordon, no annotation updates). The ERR remains the only authority for transitioning the node out of "released" state. After the managed label is removed and monitors resume, the next observation drives fault-quarantine's state transition normally.
485487

486488
### Non-goals
487489

fault-quarantine/pkg/config/config.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,16 @@ type RuleSet struct {
6767
Cordon Cordon `toml:"cordon"`
6868
}
6969

70+
// SkipNodeLabel defines a label key/value pair that causes fault-quarantine
71+
// to silently drop events for any node carrying the matching label.
72+
type SkipNodeLabel struct {
73+
Key string `toml:"key"`
74+
Value string `toml:"value"`
75+
}
76+
7077
type TomlConfig struct {
71-
LabelPrefix string `toml:"label-prefix"`
72-
CircuitBreaker CircuitBreaker `toml:"circuitBreaker"`
73-
RuleSets []RuleSet `toml:"rule-sets"`
78+
LabelPrefix string `toml:"label-prefix"`
79+
CircuitBreaker CircuitBreaker `toml:"circuitBreaker"`
80+
RuleSets []RuleSet `toml:"rule-sets"`
81+
SkipNodeLabels []SkipNodeLabel `toml:"skip-node-labels"`
7482
}

fault-quarantine/pkg/metrics/metrics.go

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ const (
2525
StatusFailed = "failed"
2626
)
2727

28+
const (
29+
labelNode = "node"
30+
labelKey = "label_key"
31+
)
32+
2833
var (
2934
// Event Processing Metrics
3035
TotalEventsReceived = promauto.NewCounter(
@@ -53,36 +58,44 @@ var (
5358
Name: "fault_quarantine_nodes_quarantined_total",
5459
Help: "Total number of nodes quarantined.",
5560
},
56-
[]string{"node"},
61+
[]string{labelNode},
5762
)
5863
TotalNodesUnquarantined = promauto.NewCounterVec(
5964
prometheus.CounterOpts{
6065
Name: "fault_quarantine_nodes_unquarantined_total",
6166
Help: "Total number of nodes unquarantined.",
6267
},
63-
[]string{"node"},
68+
[]string{labelNode},
6469
)
6570
TotalNodesManuallyUncordoned = promauto.NewCounterVec(
6671
prometheus.CounterOpts{
6772
Name: "fault_quarantine_nodes_manually_uncordoned_total",
6873
Help: "Total number of nodes manually uncordoned.",
6974
},
70-
[]string{"node"},
75+
[]string{labelNode},
7176
)
7277

7378
TotalNodesManuallyUntainted = promauto.NewCounterVec(
7479
prometheus.CounterOpts{
7580
Name: "fault_quarantine_nodes_manually_untainted_total",
7681
Help: "Total number of nodes manually untainted",
7782
},
78-
[]string{"node"},
83+
[]string{labelNode},
7984
)
8085
CurrentQuarantinedNodes = promauto.NewGaugeVec(
8186
prometheus.GaugeOpts{
8287
Name: "fault_quarantine_current_quarantined_nodes",
8388
Help: "Nodes which are currently quarantined and undergoing breakfix",
8489
},
85-
[]string{"node"},
90+
[]string{labelNode},
91+
)
92+
93+
EventsSkippedNodeLabel = promauto.NewCounterVec(
94+
prometheus.CounterOpts{
95+
Name: "fault_quarantine_events_skipped_node_label_total",
96+
Help: "Events skipped because the target node carries a configured skip label.",
97+
},
98+
[]string{labelKey},
8699
)
87100

88101
// Taint and Cordon Metrics
@@ -105,14 +118,14 @@ var (
105118
Name: "fault_quarantine_labels_applied_total",
106119
Help: "Total number of quarantine labels applied to nodes.",
107120
},
108-
[]string{"label_key"},
121+
[]string{labelKey},
109122
)
110123
LabelsRemoved = promauto.NewCounterVec(
111124
prometheus.CounterOpts{
112125
Name: "fault_quarantine_labels_removed_total",
113126
Help: "Total number of quarantine labels removed from nodes.",
114127
},
115-
[]string{"label_key"},
128+
[]string{labelKey},
116129
)
117130
CordonsApplied = promauto.NewCounter(
118131
prometheus.CounterOpts{

fault-quarantine/pkg/reconciler/reconciler.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,15 @@ func (r *Reconciler) ProcessEvent(
473473
return nil
474474
}
475475

476+
if r.isNodeSkipped(ctx, event.HealthEvent.NodeName) {
477+
span.SetAttributes(
478+
attribute.String("fault_quarantine.event.processing_status", EventProcessingStatusSkipped),
479+
attribute.String("fault_quarantine.skip.reason", "Node carries skip label"),
480+
)
481+
482+
return nil
483+
}
484+
476485
slog.DebugContext(ctx, "Processing event", "checkName", event.HealthEvent.CheckName)
477486

478487
isNodeQuarantined := r.handleEvent(ctx, event, ruleSetEvals, rulesetsConfig)
@@ -505,6 +514,43 @@ func (r *Reconciler) ProcessEvent(
505514
return isNodeQuarantined
506515
}
507516

517+
// isNodeSkipped checks whether the node carries any label from the configured
518+
// skip-node-labels list. When a match is found the event should be dropped
519+
// silently so that opted-out nodes are neither quarantined nor uncordoned.
520+
//
521+
// Fail-open: if the node is not in the informer cache (e.g. cache hasn't synced
522+
// yet on startup, or the node was deleted), the event is processed normally.
523+
// This is intentional -- a missing cache entry should not block quarantine for
524+
// nodes that are genuinely unhealthy.
525+
func (r *Reconciler) isNodeSkipped(ctx context.Context, nodeName string) bool {
526+
if len(r.config.TomlConfig.SkipNodeLabels) == 0 {
527+
return false
528+
}
529+
530+
node, err := r.k8sClient.NodeInformer.GetNode(nodeName)
531+
if err != nil {
532+
slog.DebugContext(ctx, "Node not found in informer cache, not skipping", "node", nodeName, "error", err)
533+
return false
534+
}
535+
536+
for _, skipLabel := range r.config.TomlConfig.SkipNodeLabels {
537+
if skipLabel.Key == "" {
538+
slog.WarnContext(ctx, "Ignoring skip-node-label entry with empty key")
539+
continue
540+
}
541+
542+
if val, ok := node.Labels[skipLabel.Key]; ok && val == skipLabel.Value {
543+
slog.InfoContext(ctx, "Skipping event for node with skip label",
544+
"node", nodeName, "labelKey", skipLabel.Key, "labelValue", skipLabel.Value)
545+
metrics.EventsSkippedNodeLabel.WithLabelValues(skipLabel.Key).Inc()
546+
547+
return true
548+
}
549+
}
550+
551+
return false
552+
}
553+
508554
// checkCircuitBreakerAndHalt checks if circuit breaker is tripped and returns true if processing should halt
509555
func (r *Reconciler) checkCircuitBreakerAndHalt(ctx context.Context) bool {
510556
span := tracing.SpanFromContext(ctx)

0 commit comments

Comments
 (0)