feat(mgmt-agent): add node-health detection and labeling controller (AROSLSRE-1588) - #6284
feat(mgmt-agent): add node-health detection and labeling controller (AROSLSRE-1588)#6284raelga wants to merge 16 commits into
Conversation
|
[APPROVALNOTIFIER] This PR is APPROVED This pull-request has been approved by: raelga The full list of commands accepted by this bot can be found here. The pull request process is described here DetailsNeeds approval from an approver in each of these files:
Approvers can indicate their approval by writing |
|
/retitle WIP feat(mgmt-agent): add generic node-healer controller (AROSLSRE-1588) |
|
/retitle WIP - DO NOT REVIEW YET - feat(mgmt-agent): add generic node-healer controller (AROSLSRE-1588) |
There was a problem hiding this comment.
Pull request overview
Adds a new mgmt-agent “node-healer” controller that watches kubelet Pod Events for configured failure signatures and, when thresholds/dwell are met for allow-listed nodes, performs a safety-gated remediation workflow (cordon → drain → delete) to trigger AKS reprovisioning. The controller is configured via a watched ConfigMap (hot-reload) and ships disabled + dry-run by default, with new Prometheus metrics and unit test coverage.
Changes:
- Introduces the
nodehealercontroller package (config parsing/validation, signal tracking, evaluation, remediation, and metrics) plus unit tests. - Wires the controller into
mgmt-agentleader election, adds Helm templates/values, default ConfigMap, and RBAC needed to cordon/drain/delete nodes. - Regenerates Helm golden fixture to include the new ConfigMap and deployment args.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| mgmt-agent/zz_fixture_TestHelmTemplate_dev_westus3_mgmt_1_mgmt_agent.yaml | Updated Helm golden fixture to include node-healer ConfigMap, args, and RBAC deltas. |
| mgmt-agent/values.yaml | Adds default node-healer chart values (disabled + dry-run default config). |
| mgmt-agent/pkg/controller/nodehealer/config.go | Implements YAML config model, defaults, compilation (regex), and validation. |
| mgmt-agent/pkg/controller/nodehealer/config_test.go | Unit tests for config parse/override/validation/allow-list behavior. |
| mgmt-agent/pkg/controller/nodehealer/consts.go | Shared constants for patch fieldManager/annotations (and related controller constants). |
| mgmt-agent/pkg/controller/nodehealer/controller.go | Core controller loop (queue, resync, evaluation, remediation orchestration, hot-reload). |
| mgmt-agent/pkg/controller/nodehealer/detector.go | Node evaluation logic (safety gating + detector firing → remediation selection). |
| mgmt-agent/pkg/controller/nodehealer/detector_test.go | Unit tests for evaluation guards and firing/remediation behavior. |
| mgmt-agent/pkg/controller/nodehealer/metrics.go | Prometheus metrics definitions + registration helper. |
| mgmt-agent/pkg/controller/nodehealer/remediation.go | Remediation engine (cordon/drain/delete, gating, events/metrics). |
| mgmt-agent/pkg/controller/nodehealer/remediation_test.go | Unit tests for remediation actions (including dry-run and pod filtering). |
| mgmt-agent/pkg/controller/nodehealer/signals.go | Event-informer signal ingestion + sliding-window tracking logic. |
| mgmt-agent/pkg/controller/nodehealer/signals_test.go | Unit tests for tracker window/dwell/forget semantics. |
| mgmt-agent/deploy/templates/node-healer-config.yaml | New Helm template to render the watched node-healer ConfigMap. |
| mgmt-agent/deploy/templates/deployment.yaml | Adds node-healer configmap/key args to mgmt-agent deployment. |
| mgmt-agent/deploy/templates/clusterrole.yaml | Expands RBAC to support node patch/delete and pod delete for drain. |
| mgmt-agent/cmd/options.go | Wires node-healer informers, configmap watcher, recorder, and controller startup under leader election. |
| mgmt-agent/cmd/cmd.go | Updates CLI help text documenting the new controller. |
Comments suppressed due to low confidence (1)
mgmt-agent/pkg/controller/nodehealer/remediation.go:201
- The
remediationparameter is unused in drain(), which is likely to be reported by the enabled golangci-lintunusedlinter. Use it or rename it to_.
func (r *Remediator) drain(ctx context.Context, name string, dryRun bool, remediation string) error {
37e547e to
9843b89
Compare
9843b89 to
6e81bd9
Compare
|
Force-pushed (WIP): rescoped this PR to phase 1 — detection + labelling only (per review feedback to ship the smallest useful increment first). Removed the cordon/drain/delete remediation path, the Confirmer/Scaler hooks, and the concurrency/cooldown/pre-scale safety machinery; the controller now sets/clears a Second push in this batch addresses the Copilot review: removed a dead const, made |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
mgmt-agent/pkg/controller/nodehealer/controller.go:125
- Tracker retention is computed only once from the initial config (Default) and never updated on hot-reload. If a ConfigMap reload increases a detector window or dwell, the tracker will keep pruning history too aggressively and firings/dwell timing will be incorrect until enough new history accumulates (or may never fire as expected). Update retention when applying a new config.
// SetConfig atomically replaces the configuration. The provided config must
// already be compiled and validated (use Parse). It also updates the tracker's
// retention so a hot-reload that widens a detector window/dwell does not prune
// older signals prematurely.
func (c *NodeHealer) SetConfig(cfg Config) {
c.config.Store(&cfg)
6e81bd9 to
55f59c8
Compare
55f59c8 to
c4f2c1d
Compare
|
Pushed c4f2c1d addressing the latest Copilot review round (all in-scope):
Build, vet, and |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (1)
mgmt-agent/deploy/templates/clusterrole.yaml:17
- The node-healer uses a MergePatch to modify nodes (Nodes().Patch), so the ClusterRole does not appear to need the broader 'update' verb on nodes. Dropping it would reduce privileges (least-privilege) while still supporting the current implementation.
verbs:
- get
- list
- watch
- patch
- apiGroups:
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (3)
mgmt-agent/pkg/controller/nodehealth/controller.go:247
- resyncAll() lists and enqueues every node on each tick. Without the node-name allow-list mentioned in the PR description, this can cause unnecessary steady-state work (per-node gather scans pods and events) and elevated API/informer cache churn once enabled. Consider scoping resync/enqueue to only nodes that can ever be candidates (e.g., name allow-list and/or the SWIFT-v2 label).
for _, n := range nodes {
if n.Labels[labelKey] == labelValue {
wedged++
}
c.enqueue(n.Name)
mgmt-agent/pkg/controller/nodehealth/config.go:25
- The PR description says detectors/failure modes are declarative and configurable, but this package explicitly documents that detection is hard-coded in Go and that runtime config is only {enabled,dryRun}. Please align the PR description with the implemented behavior (or implement the config-driven detector engine described).
// Detection is hard-coded and modular: each fault family is a Go detector that
// reuses a shared toolkit (event-signature match, a sustained-storm floor, and
// the load-bearing zero-successful-start check) and is a pure function of the
// node's current state, so it is exhaustively unit-tested with no API server.
// The only runtime configuration is the two operational switches the rollout
// needs, enabled and dryRun; there is no config-driven detection engine.
mgmt-agent/deploy/templates/clusterrole.yaml:29
- RBAC: the new node-health controller uses shared informers/listers for pods/events and does not make direct GET calls. The added
getverb on pods/events increases permissions without an apparent need; list/watch (for informers) plus create/patch/update (for event recording) should be sufficient.
- apiGroups:
- ""
resources:
- pods
verbs:
- get
…IFT-v2 nodes (AROSLSRE-1588) Addresses PR review feedback: - Drop the unused get verb from the pods and events grants. The controller reads pods and events only through informer listers/indexers (list+watch), and records events via create/patch/update; it never issues an API get. - Scope resyncAll to nodes carrying the SWIFT-v2 label, since the only detector applies to SWIFT-v2 nodes and sweeping the rest every interval is wasted steady-state work. Correctness is unchanged: non-SWIFT nodes already resolved to DecisionUnknown and were never mutated. Regenerated the mgmt-agent Helm template fixture accordingly.
|
@copilot thanks for the review. Pushed cc7672d to address the two low-confidence points, and a note on the inline comments: Addressed in code:
Inline comments (label name / node-name allow-list) are stale: the PR description was reworked before this review and already matches the code, it documents the No further action or commit needed from you, this is resolved. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
mgmt-agent/cmd/cmd.go:53
- This section adds a third controller (node-health), but the earlier help text still says “mgmt-agent runs two controllers under a single leader election”. Please update that introductory sentence to “three controllers” to keep the CLI help consistent.
3. Node-health controller: watches kubelet Pod Events for hard-coded failure
signatures (e.g. SWIFT delegated-NIC teardown that leaves a node Ready but
unable to start pods) and, when a SWIFT-v2 node crosses a detector threshold,
sets a health label on the node (and clears it when the node recovers) so an
out-of-band consumer can act on it. It watches only SWIFT-v2 nodes and ships
disabled by default.
mgmt-agent/pkg/controller/nodehealth/labeler.go:68
- label() short-circuits solely on the label being present, so it won’t restore/repair the detector/reason/observed-at annotations if they’re missing or drifted (e.g., manual edit). That can leave a node marked wedged without the explanatory metadata the controller expects to publish.
if node.Labels[labelKey] == labelValue {
logger.V(4).Info("node already labelled wedged")
return false, nil
}
mgmt-agent/pkg/controller/nodehealth/metrics.go:35
- This PR introduces new Prometheus series under the nodehealth subsystem. CONTRIBUTING.md Pull Request Standards require before/after screenshots for metrics changes; please add a /metrics scrape snippet or equivalent before/after evidence to the PR description.
const metricsSubsystem = "nodehealth"
var (
detectionsTotal = metrics.NewCounterVec(
&metrics.CounterOpts{
Subsystem: metricsSubsystem,
Name: "detections_total",
Help: "Number of times a detector caused a node to be labelled wedged, counted on the label transition rather than on every re-evaluation.",
StabilityLevel: metrics.ALPHA,
},
[]string{"detector"},
)
…LSRE-1588) The node-health controller is the third controller, but the intro line still said "two controllers under a single leader election".
|
@copilot thanks, addressed the re-review:
No further action or commit needed from you, this is resolved. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
mgmt-agent/pkg/controller/nodehealth/labeler.go:61
label()ignoressnap.reason(the detector’s human-readable explanation) and instead stores/logs onlysnap.reasonString()(counts). This makes theannotationReasonvalue inconsistent with its own docs ("human-readable explanation") and leavessnap.reasoneffectively unused outside tests. Consider preferringsnap.reasonwhen it’s set (falling back toreasonString()only when empty).
reason := snap.reasonString()
patch, err := metadataPatch(
map[string]*string{labelKey: ptr(labelValue)},
map[string]*string{
annotationDetector: ptr(detector),
mgmt-agent/pkg/controller/nodehealth/metrics.go:33
- This PR adds new /metrics series under the
nodehealth_subsystem. Per CONTRIBUTING.md Pull Request Standards, changes that affect metrics must include before/after screenshots (a /metrics scrape excerpt is usually sufficient if there’s no dashboard yet). Please add that evidence to the PR description before approval.
const metricsSubsystem = "nodehealth"
var (
detectionsTotal = metrics.NewCounterVec(
&metrics.CounterOpts{
Subsystem: metricsSubsystem,
Name: "detections_total",
Help: "Number of times a detector caused a node to be labelled wedged, counted on the label transition rather than on every re-evaluation.",
StabilityLevel: metrics.ALPHA,
},
|
/test images Retesting: this is an environmental flake unrelated to this PR's diff. Job RUN curl -sfL https://mirror.openshift.com/pub/openshift-v4/clients/ocp/${OC_MIRROR_4_18_VERSION}/oc-mirror.tar.gz ... This PR only changes |
|
/test images Still an environmental flake, retesting once more. The retest (build 2082738534065639424) failed again in the STEP 11/11: RUN curl -sfL https://github.com/mikefarah/yq/... -o yq.tar.gz ...
Evidence it is not this PR:
If this recurs, I'll switch to periodic backoff rather than spam retests. |
|
/test images Periodic backoff retest (1/4). |
|
/test images Periodic backoff retest (2/4). |
|
/test images Periodic backoff retest (3/4). |
|
/test images Retesting because this is a CI network failure outside this PR's mgmt-agent change. The images job 2082756282724388864 built the new Another open PR's images job failed during the same period on DNS resolution while downloading the Bicep CLI, so this is not specific to this diff. |
…eparate controller (AROSLSRE-1588)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
mgmt-agent/pkg/controller/nodehealth/metrics.go:32
- This PR adds new
nodehealth_*Prometheus series, which triggers the repo PR standard to include before/after screenshots for metrics changes (CONTRIBUTING.md Pull Request Standards §5). The PR description currently includes a text scrape, but not screenshots; please add the required screenshots (e.g., /metrics in the browser or a Grafana panel) before approval.
&metrics.CounterOpts{
Subsystem: metricsSubsystem,
Name: "detections_total",
Help: "Number of times a detector caused a node to be labelled wedged, counted on the label transition rather than on every re-evaluation.",
StabilityLevel: metrics.ALPHA,
mgmt-agent/pkg/controller/nodehealth/config.go:25
- The PR description says runtime config includes both
enabledanddryRun, and the Metrics section shows anodehealth_dry_rungauge. In this implementation, the config only supportsenabled(nodryRunfield) and the metrics registered are detections/label_actions/wedged_nodes only. Please align the PR description (and example scrape) with the actual shipped behavior, or add the missing dry-run config+metric if that’s still a requirement.
// Detection is hard-coded and modular: each fault family is a Go detector that
// reuses a shared toolkit (event-signature match, a sustained-storm floor, and
// the load-bearing zero-successful-start check) and is a pure function of the
// node's current state, so it is exhaustively unit-tested with no API server.
// The only runtime configuration is the single operational switch the rollout
// needs, enabled; there is no config-driven detection engine.
…or interface Split detector.go into an engine (Detector interface + registry + decide), a shared signatureDetector base carrying the common evaluation ops, and swift_vf.go holding only the SWIFT family specifics. No behavior change.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
mgmt-agent/pkg/controller/nodehealth/config.go:42
- The PR description states the node-health ConfigMap supports both
enabledanddryRun, but the implementation only defines/loads a singleenabledboolean (nodryRunfield or handling). Please either update the PR description to match the implemented config surface, or add the missingdryRunflag to the config + controller wiring if it’s intended to ship in Phase 1.
// Config holds the node-health controller's operational switch, delivered
// via a watched ConfigMap so it can be flipped without a redeploy. Detection
// logic, thresholds, and the SWIFT-v2 node scoping are hard-coded, not
// configured.
type Config struct {
// Enabled is a hard off switch. When false the controller still runs its
// informers but records no state, enqueues nothing, and takes no action.
Enabled bool `json:"enabled"`
}
mgmt-agent/pkg/controller/nodehealth/metrics.go:35
- This PR introduces new
nodehealth_*metrics. CONTRIBUTING.md Pull Request Standards require before/after screenshots for metrics/observability changes; the PR should include screenshots (not just text output) demonstrating the new series on /metrics or in a dashboard/panel.
detectionsTotal = metrics.NewCounterVec(
&metrics.CounterOpts{
Subsystem: metricsSubsystem,
Name: "detections_total",
Help: "Number of times a detector caused a node to be labelled wedged, counted on the label transition rather than on every re-evaluation.",
StabilityLevel: metrics.ALPHA,
},
[]string{"detector"},
)
mgmt-agent/pkg/controller/nodehealth/metrics.go:55
- The PR description’s metrics section includes a
nodehealth_dry_rungauge, but the node-health metrics registered in this PR do not define or register adry_runmetric. Please either remove it from the PR description/example scrape, or add/register the gauge (and ensure it is set from config/state).
const metricsSubsystem = "nodehealth"
var (
detectionsTotal = metrics.NewCounterVec(
&metrics.CounterOpts{
Subsystem: metricsSubsystem,
Name: "detections_total",
Help: "Number of times a detector caused a node to be labelled wedged, counted on the label transition rather than on every re-evaluation.",
StabilityLevel: metrics.ALPHA,
},
[]string{"detector"},
)
labelActionsTotal = metrics.NewCounterVec(
&metrics.CounterOpts{
Subsystem: metricsSubsystem,
Name: "label_actions_total",
Help: "Number of label/unlabel actions, by action and result.",
StabilityLevel: metrics.ALPHA,
},
[]string{"action", "result"},
)
wedgedNodes = metrics.NewGauge(
&metrics.GaugeOpts{
Subsystem: metricsSubsystem,
Name: "wedged_nodes",
Help: "Number of nodes currently carrying the wedged health label, as observed by the node-health controller. Reported as 0 while the controller is disabled.",
StabilityLevel: metrics.ALPHA,
},
)
)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
mgmt-agent/pkg/controller/nodehealth/config.go:42
- The PR description states node-health has two hot-reloadable switches (enabled + dryRun) and exposes a nodehealth_dry_run metric, but the shipped configuration (Config) only supports Enabled and there is no dry-run metric in the nodehealth subsystem. Please either implement the dryRun switch/metric as described or update the PR description to match the current Phase 1 scope (enabled-only + no dry-run metric).
// Config holds the node-health controller's operational switch, delivered
// via a watched ConfigMap so it can be flipped without a redeploy. Detection
// logic, thresholds, and the SWIFT-v2 node scoping are hard-coded, not
// configured.
type Config struct {
// Enabled is a hard off switch. When false the controller still runs its
// informers but records no state, enqueues nothing, and takes no action.
Enabled bool `json:"enabled"`
}
…ignals and Source.Host indexing (AROSLSRE-1588)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (4)
mgmt-agent/cmd/cmd.go:53
- The help text says the node-health controller "watches only SWIFT-v2 nodes", but the implementation starts cluster-wide Node/Pod/Event informers and only acts (labels/unlabels) when a detector applies. Please adjust this wording to avoid implying informer-level filtering.
3. Node-health controller: watches kubelet Pod Events for hard-coded failure
signatures (e.g. SWIFT delegated-NIC teardown that leaves a node Ready but
unable to start pods) and, when a SWIFT-v2 node crosses a detector threshold,
sets a health label on the node (and clears it when the node recovers) so an
out-of-band consumer can act on it. It watches only SWIFT-v2 nodes and ships
disabled by default.
mgmt-agent/pkg/controller/nodehealth/detectors/swift_vf.go:28
- This comment says the controller "uses it to scope its node informer" to SWIFT-v2 nodes, but the controller currently does not apply a label selector to the Node informer (it only scopes the periodic resync sweep / gauge). Please reword to match the actual scoping mechanism.
// SwiftV2LabelKey is the AKS-managed label marking a node as SWIFT-v2
// delegated-NIC capable. A node without a delegated secondary NIC cannot
// suffer a VF teardown, so this label scopes the swift-vf-teardown detector.
// It is exported because the controller uses it to scope its node informer to
// the only nodes any detector can apply to today.
mgmt-agent/pkg/controller/nodehealth/detectors/README.md:112
- The README states the controller "scopes its node informer" to SWIFT-v2 nodes, but the current wiring uses a cluster-wide Node informer (it only scopes the periodic resync sweep to SWIFT-v2). Please adjust this to avoid misleading future readers/operators.
`SwiftV2LabelKey`/`Value` are exported because the controller scopes its node
informer to SWIFT-v2 nodes (the only nodes any detector applies to today), so it
does not waste work watching nodes nothing can fire on.
mgmt-agent/values.yaml:25
- PR description claims a hot-reloadable
dryRunswitch and anodehealth_dry_runmetric, but the chart values/config currently expose onlyenabled: false. Either implementdryRunend-to-end (values/config parsing + metric) or update the PR description/metrics section so the documented operational surface matches what ships.
# thresholds, and the SWIFT-v2 node scoping are hard-coded; the only runtime
# config is this switch. Defaults to disabled; enable per environment or by
# editing the ConfigMap live (hot-reload).
config:
enabled: false
… detection (AROSLSRE-1588) Move the success signal from a point-in-time scan of currently-cached Pods to a stateful per-node history. A short-lived pod whose sandbox came up and was then deleted no longer vanishes from the success evidence, so a node with a real recovery in the window is not misread as wedged. - Snapshot.SuccessCount -> RecentSuccess; Decide takes lastSuccessAt and sets RecentSuccess when it falls in-window, never scanning the passed-in pods. - signatureDetector.Evaluate drops the success scan; startedInWindow becomes the exported nil-safe SuccessAt, returning the True-transition time. - Controller records the most recent non-host-network PodReadyToStartContainers=True transition per node from pod add/update/delete (surviving GC), guarded by obsMu, pruned by MaxWindow. Observation warm-up resets on cache sync and on a disabled->enabled transition.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (5)
mgmt-agent/pkg/controller/nodehealth/config.go:56
- Config parsing uses yaml.Unmarshal, which will silently ignore unknown fields. That makes typos (or fields mentioned elsewhere, like a future “dryRun”) look like they applied but actually fall back to defaults. Using UnmarshalStrict helps fail fast on misconfiguration and avoids “enabled stayed false” surprises.
if err := yaml.Unmarshal(data, &cfg); err != nil {
mgmt-agent/pkg/controller/nodehealth/controller.go:253
- recordPodSuccess() updates the per-node successAt map even when the controller is disabled. While disabled, observation() is never called so entries are never pruned, and over time this can grow without bound (and it also contradicts Config.Enabled’s “records no state” contract). Gate success recording on Enabled so disabled mode stays low-impact and bounded.
func (c *Controller) recordPodSuccess(obj interface{}) {
if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok {
obj = tombstone.Obj
}
pod, ok := obj.(*corev1.Pod)
mgmt-agent/pkg/controller/nodehealth/controller.go:185
- This comment says pod handlers “still record successes” while disabled, but that behavior conflicts with the Enabled contract in config.go and also risks unbounded successAt growth while disabled. If you gate recordPodSuccess on Enabled, this comment should be updated to avoid misleading future maintainers/operators.
This issue also appears on line 249 of the same file.
// A disabled->enabled transition restarts observation: while disabled the
// pod handlers still record successes, but the observedSince warm-up must
// begin now so the success signal is treated as indeterminate until a full
// window has been watched under the enabled controller.
mgmt-agent/pkg/controller/nodehealth/config.go:42
- The PR description calls out a hot-reload ConfigMap with both
enabledanddryRun, and lists anodehealth_dry_runmetric. In this implementation, Config only containsenabledand the nodehealth metrics registered are detections_total/label_actions_total/wedged_nodes. Either implementdryRunend-to-end (config, wiring, metric) or update the PR description/contract to match what actually shipped.
This issue also appears on line 56 of the same file.
type Config struct {
// Enabled is a hard off switch. When false the controller still runs its
// informers but records no state, enqueues nothing, and takes no action.
Enabled bool `json:"enabled"`
}
mgmt-agent/pkg/controller/nodehealth/metrics.go:31
- This PR introduces new
/metricsseries under thenodehealth_*subsystem. Per CONTRIBUTING.md Pull Request Standards, graph/UI/metrics changes require before/after screenshots; please add them to the PR description (e.g., a before/after/metricsscrape view or a dashboard/panel screenshot once available).
detectionsTotal = metrics.NewCounterVec(
&metrics.CounterOpts{
Subsystem: metricsSubsystem,
Name: "detections_total",
Help: "Number of times a detector caused a node to be labelled wedged, counted on the label transition rather than on every re-evaluation.",
…per-pod sustained dwell for the floor (AROSLSRE-1588)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 22 changed files in this pull request and generated no new comments.
Suppressed comments (5)
mgmt-agent/pkg/controller/nodehealth/controller.go:210
- resyncAll() intentionally scopes to SWIFT-v2 nodes to avoid wasted work, but enqueueNodeObject() still enqueues every Node update when Enabled. Since Node objects update frequently, this can drive substantial reconcile/gather churn for nodes that no detector can ever apply to (today). Consider scoping node-driven enqueues to the current applicability label (or otherwise gating enqueues) so Enabled doesn’t amplify steady-state load cluster-wide.
func (c *Controller) enqueueNodeObject(obj interface{}) {
if name, ok := nodeName(obj); ok {
c.enqueue(name)
}
}
mgmt-agent/pkg/controller/nodehealth/controller.go:256
- recordPodSuccess() updates the per-node successAt map even when the controller is disabled. That both contradicts Config.Enabled's doc comment (“records no state”) and can cause unnecessary CPU/memory churn while the controller ships disabled by default. Gate success recording on Enabled so disabled mode is truly low-impact.
func (c *Controller) recordPodSuccess(obj interface{}) {
if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok {
obj = tombstone.Obj
}
pod, ok := obj.(*corev1.Pod)
mgmt-agent/pkg/controller/nodehealth/controller.go:185
- The disabled->enabled transition comment says pod handlers record successes while disabled, but (with success recording gated by Enabled) this becomes inaccurate. Please update this comment so it reflects the actual behavior: observation warm-up restarts on enable so the controller requires a full window before trusting the success signal.
// A disabled->enabled transition restarts observation: while disabled the
// pod handlers still record successes, but the observedSince warm-up must
// begin now so the success signal is treated as indeterminate until a full
// window has been watched under the enabled controller.
mgmt-agent/pkg/controller/nodehealth/consts.go:21
- ControllerName's doc comment claims it also feeds the event recorder component and log fields, but it is only used for the workqueue name. This mismatch is misleading (and was likely left behind after removing the unused recorder-component const). Update the comment to match actual usage, or wire ControllerName into the recorder/logger setup.
// ControllerName is the single source of truth for this controller's name.
// It feeds the workqueue name (which surfaces as a Prometheus label), the
// event recorder component, and log fields, so those never drift.
mgmt-agent/pkg/controller/nodehealth/metrics.go:28
- The PR description claims two runtime switches (enabled + dryRun) and a nodehealth_dry_run metric, but the implementation currently has only Config.Enabled and no dry-run metric/gauge. Please either update the PR description to match the implemented Phase-1 scope, or add the dryRun switch + metric (which will require changes across config parsing, Helm values/templates, and controller metrics wiring).
const metricsSubsystem = "nodehealth"
var (
detectionsTotal = metrics.NewCounterVec(
&metrics.CounterOpts{
…588) A pod in Succeeded or Failed has its sandbox torn down on completion, so its PodReadyToStartContainers condition goes False and stays False arbitrarily long after the pod ran fine. Counting those toward the sustained-failure floor makes ordinary Job and CronJob turnover look like a wedge. The node captured in the uksouth incident carried four such pods, enough on its own to reach the floor of three. Replaying that capture without this guard, Decide returns Wedged on turnover alone. Adds production_evidence_test.go, which pins the four kubelet sandbox-failure signatures, the node labels and Ready state, and both false-signal scenarios against the real capture.
…ROSLSRE-1588) Three coupled gaps in the label lifecycle, kept in one commit because they only make sense together. Decide gained DecisionNotApplicable, returned when no detector applies to the node. It is checked ahead of the Ready gate: a node no detector owns is not ours to hold a label on whether it is Ready or not. Without it a node that loses its SWIFT labels keeps a wedged label forever, since the old path returned Unknown and Unknown deliberately retains. label() and unlabel() re-read the node before writing, so a stale informer cache no longer emits a duplicate transition. label() is also record-aware now: it short-circuits only when both the label and the detector annotation match, which self-heals a stripped or outdated detection record. The reason annotation is not reconciled continuously on purpose, it embeds live evidence counts and would rewrite every wedged node on every pass. resyncAll now sweeps the union of the SWIFT selector and the wedged-label selector, so a node that needs cleanup is still visited after a restart even though it no longer matches the SWIFT selector. The gauge is counted over that same set and reset to zero when the controller is disabled.
…AROSLSRE-1588) mgmt-agent ships through an EV2 Helm step, not a continuous GitOps reconcile, so a live edit to the ConfigMap holds until the next rollout and is then restored from git. The old values.yaml comment implied it would be reverted immediately. Also corrects the detections_total help text, which counts detection-record changes rather than only label transitions, and documents DecisionNotApplicable and the widened sweep in the detectors README.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (7)
mgmt-agent/pkg/controller/nodehealth/detectors/production_evidence_test.go:72
- This hard-coded node name appears to be a real production identifier, but it is not required for the detector logic exercised by these tests. Consider switching to a clearly-redacted/example value to avoid committing production environment details.
Name: "aks-userswft2-40171262-vmss000007",
mgmt-agent/pkg/controller/nodehealth/detectors/production_evidence_test.go:107
- This hard-coded namespace value appears to be a real production namespace, but namespace does not affect the detector logic in this test. Consider replacing it with a clearly-redacted/example value.
ns := "ocm-arohcpprod-2rn5j76cjp7rhpkjbkgh4549ssub4at2-e9z7v8c3g8f5s0q"
mgmt-agent/pkg/controller/nodehealth/detectors/production_evidence_test.go:141
- This EventSource.Host value appears to be a real production node identifier, but Source.Host is not used by the detector logic exercised in this test. Consider replacing it with the same redacted/example host used elsewhere in this file.
Source: corev1.EventSource{Host: "aks-userswft2-40171262-vmss000007"},
mgmt-agent/pkg/controller/nodehealth/detectors/production_evidence_test.go:166
- This hard-coded host constant appears to be a real production node identifier, but the specific value is not required for the detector logic in this test. Consider using a redacted/example host value.
const host = "aks-userswft2-40171262-vmss000007"
mgmt-agent/pkg/controller/nodehealth/controller.go:59
- The Controller doc comment says it has "two operational switches", but the current Config only exposes a single switch (Enabled). This mismatch is likely to confuse future maintainers and operators reading godoc.
// Controller is the level-driven controller that ties detection (the pure
// detectors.Decide function, fed from shared informers) to a label/unlabel
// reconcile of wedged nodes. Its two operational switches are hot-reloadable
// via SetConfig.
mgmt-agent/pkg/controller/nodehealth/config.go:41
- Config.Enabled is documented as "records no state", but the implementation records per-node success timestamps even while disabled (recordPodSuccess is not gated). Either gate the recording on Enabled, or adjust this comment so it matches the actual behavior.
// Enabled is a hard off switch. When false the controller still runs its
// informers but records no state, enqueues nothing, and takes no action.
Enabled bool `json:"enabled"`
mgmt-agent/pkg/controller/nodehealth/detectors/production_evidence_test.go:30
- This comment embeds specific production cluster/node identifiers. Since the tests only need the message strings and SWIFT-related node shape (not the real host/cluster names), consider redacting these identifiers to avoid unnecessarily committing production environment details.
This issue also appears in the following locations of the same file:
- line 72
- line 107
- line 141
- line 166
// This file pins the detector against evidence captured from a real wedged
// production node (aks-userswft2-40171262-vmss000007, prod-uksouth-mgmt-1,
// 2026-07-27). The strings and the node shape are taken verbatim from that
// capture, so a future edit to the signatures or the applicability label that
// would have missed the real incident fails here.
Relates to AROSLSRE-1588
Implements the design in #6298 (
docs/controllers/node-health.md).What
Adds a node-health controller to
mgmt-agent. It watches management-cluster Nodes, Pods, and kubelet PodEventsthrough shared informers and, when a SWIFT-v2 node isReadybut wedged, labels the node (node-health.aro-hcp.azure.com/status=wedged, plus detector/reason/observed-at annotations) so a separate mitigation controller can act on it. This controller does detection plus a label/unlabel flow only, it does not cordon, drain, taint, or delete nodes; all disruptive mitigation is a separate controller.Detection is hard-coded and modular, not config-driven:
detectorover a shared toolkit: event-signature match, a sustained-storm floor, a per-pod dwell, and the load-bearing zero-successful-start discriminator that separates a hard wedge (VF gone) from a flap.decide(node, events, pods, now, observedSince, lastSuccessAt)with no API access, fed from a pod-by-node indexer and an event-by-node indexer keyed onEvent.Source.Host, so it is exhaustively table-tested. Events are correlated to a specific Pod byInvolvedObject.UID, never by name, and are used only to classify failing pods, never counted.PodReadyToStartContainers=Falsecondition has held past the dwell, so one long-stuck pod plus a burst of brand-new ones does not fire. Success is a recorded per-node history ofPodReadyToStartContainers=Truetransitions, so it survives Event and Pod garbage collection.Readybut every pod sandbox fails withno such network interface/network is unreachable/mtpnc is not ready/ DHCP-discover timeouts.kubernetes.azure.com/podnetwork-swiftv2-enabled=true, not a node-name allow-list.Runtime configuration is a single operational switch delivered via a watched ConfigMap (
mgmt-agent-node-health, hot-reload):enabled, a hard off switch. Detection thresholds are deliberately hard-coded as constants in each detector's Go file, not configured, so the whole detector stays a pure, exhaustively testable unit. The controller ships disabled by default.Why
The SWIFT-v2 delegated-NIC teardown leaves a node
Readywhile every new pod fails to get a sandbox, so the scheduler keeps placing work on a node that cannot run it. We hit this in production (australiaeast, uksouth) and mitigated it by hand. Labeling the node lets an out-of-band consumer drain or replace it automatically. Hard-coding the detection (instead of a config-driven engine) keeps the thresholds and the zero-success rule under code review and test, where they belong, and avoids a live production lever on a safety trigger.Testing
go build ./...,go vet ./...clean on themgmt-agentmodule.go test ./pkg/controller/nodehealth/...: table-drivendecide()cases (NotReady, no-detector, sub-floor, some-success flap, sustained wedge, per-pod dwell not met, one-old-plus-new-does-not-fire, event-UID correlation, recorded-success recovery), the recorded per-node success history (SuccessAt), plus config parse/validate and labeler apply/clear/idempotency.controller_test.go: white-box controller tests covering enable-gated enqueue, per-node observation and success recording across pod add/update/delete, hot-enable observation reset, success pruning past the max window, the informer index funcs, and end-to-endsyncHandlerwedge/recovery. Package coverage 55.8%, detectors 78.1%.make update-helm-fixtures).detector/reason/observed-atannotations +NodeHealthLabelledEvent), and recovery/unlabel.Metrics
This PR adds a
nodehealthPrometheus subsystem (all ALPHA), exposed on the existing mgmt-agent/metricsendpoint:nodehealth_detections_total{detector},nodehealth_label_actions_total{action,result}, andnodehealth_wedged_nodes.Before: no
nodehealth_*series exist (the subsystem is new in this PR).After (scraped from the leader mgmt-agent pod on the dev cluster, after the live wedge + recovery test):
The counters recorded exactly one label and one unlabel over the synthetic wedge/recovery cycle;
wedged_nodesis back to 0 after cleanup.