|
| 1 | +# Tutorial: Plugin Custom Remediation |
| 2 | + |
| 3 | +This tutorial walks you through extending NVSentinel's **fault-remediation** stage with your own |
| 4 | +repair automation — for example, a custom controller. |
| 5 | + |
| 6 | +By the end you will understand: |
| 7 | + |
| 8 | +- Where custom remediation fits in the NVSentinel pipeline. |
| 9 | +- How to register a **custom remediation action** with no NVSentinel code changes. |
| 10 | +- The status-condition completion contract your controller must implement. |
| 11 | + |
| 12 | +> **Who is this for?** Teams **already running NVSentinel** — who need a repair step |
| 13 | +> NVSentinel does not ship out of the box (CSP RMA tickets, multi-step orchestration, …) instead of |
| 14 | +> the built-in reboot / terminate / GPU-reset actions. |
| 15 | +
|
| 16 | +> **Just want the AI to do it?** Jump to [Appendix: One-shot AI prompt](#appendix-one-shot-ai-prompt). |
| 17 | +
|
| 18 | +--- |
| 19 | + |
| 20 | +## 1. Where custom remediation fits |
| 21 | + |
| 22 | +NVSentinel remediates a faulty node in stages: |
| 23 | + |
| 24 | +```text |
| 25 | +health-monitor → fault-quarantine → node-drainer → fault-remediation → your system |
| 26 | +``` |
| 27 | + |
| 28 | +**fault-remediation** is the extension point. After a node is cordoned and drained, fault-remediation: |
| 29 | + |
| 30 | +1. Resolves the health event's **recommended action** (a built-in enum value or a custom string). |
| 31 | +2. Looks up a matching entry in the `maintenance.actions` Helm config. |
| 32 | +3. Renders a **Go template** into a Kubernetes Custom Resource. |
| 33 | +4. **Creates** that CR in the cluster. |
| 34 | +5. **Polls** the CR's status conditions (`completeConditionType`) to avoid duplicate repair requests. |
| 35 | + |
| 36 | +Your controller watches the CR and performs the actual repair. |
| 37 | + |
| 38 | +```text |
| 39 | + fault-remediation ──create CR──► Kubernetes API ◄──watch── Your controller |
| 40 | + ▲ │ |
| 41 | + └── poll completeConditionType ┘ |
| 42 | + (repair + set status condition) |
| 43 | +``` |
| 44 | + |
| 45 | +### Built-in vs custom actions |
| 46 | + |
| 47 | +| Action source | Example | Config key in `maintenance.actions` | |
| 48 | +|---------------|---------|-------------------------------------| |
| 49 | +| Built-in enum | `RESTART_VM`, `COMPONENT_RESET`, `REPLACE_VM` | `RESTART_VM`, `COMPONENT_RESET`, … | |
| 50 | +| Custom string ([ADR-036](../designs/036-custom-remediation-actions.md)) | `recommendedAction: CUSTOM`, `customRecommendedAction: hardware-rma` | `hardware-rma` | |
| 51 | + |
| 52 | +--- |
| 53 | + |
| 54 | +## 2. Integration approach |
| 55 | + |
| 56 | +fault-remediation creates a Kubernetes CR for your action; your controller watches it, runs the |
| 57 | +repair, and reports completion via a status condition. |
| 58 | + |
| 59 | +### Why your CR must expose status conditions |
| 60 | + |
| 61 | +fault-remediation's status checker only understands **Kubernetes-style conditions** — it looks for a |
| 62 | +`status.conditions[]` entry whose `type` equals `completeConditionType` and reads its `status` |
| 63 | +(`True` → done, `False` → failed/retry). |
| 64 | + |
| 65 | +Many external orchestrators report completion differently (e.g. a phase field, a job exit code, or a |
| 66 | +ticket status). Use a thin wrapper CRD whose controller translates external completion into |
| 67 | +`status.conditions[Complete]=True`. |
| 68 | + |
| 69 | +--- |
| 70 | + |
| 71 | +## 3. The completion contract |
| 72 | + |
| 73 | +Whatever CR NVSentinel creates, fault-remediation decides "is this repair done?" by reading a status |
| 74 | +condition: |
| 75 | + |
| 76 | +```yaml |
| 77 | +status: |
| 78 | + conditions: |
| 79 | + - type: <completeConditionType> # e.g. "Complete" |
| 80 | + status: "True" # True = success, False = failed (retry allowed) |
| 81 | + reason: RepairSucceeded |
| 82 | + message: Node hardware repaired and validated |
| 83 | +``` |
| 84 | +
|
| 85 | +Behavior of the status checker: |
| 86 | +
|
| 87 | +- **Condition `True`** → repair succeeded; equivalent events are marked covered. |
| 88 | +- **Condition `False`** → repair failed; NVSentinel drops the group and **creates a new CR to retry**. |
| 89 | + There is no "terminal failure" state — if a repair is unrecoverable, keep the condition off `True` |
| 90 | + (leave it `Unknown`/absent) rather than setting `False`, or stop requeueing after your own retry budget. |
| 91 | +- **Condition missing / `Unknown`** → repair in progress; NVSentinel **skips** creating a duplicate CR. |
| 92 | + |
| 93 | +fault-remediation derives an **effective equivalence group** from the matched action — and, when |
| 94 | +`impactedEntityScope` is set, the impacted entity (e.g. `reset-GPU-123`). It skips creating a new CR |
| 95 | +only while a CR in that effective group (or a superseding group) is in progress or already succeeded; |
| 96 | +it does **not** deduplicate every repair on the node. |
| 97 | + |
| 98 | +`supersedingEquivalenceGroups` lets a broader action cover a narrower one. The **subordinate** action |
| 99 | +lists the group that supersedes it. For example, a node reboot has the same effect as an RMA, so the |
| 100 | +`hardware-rma` action lists `restart`: |
| 101 | + |
| 102 | +```yaml |
| 103 | +hardware-rma: |
| 104 | + equivalenceGroup: external-rma |
| 105 | + supersedingEquivalenceGroups: [restart] # a restart supersedes external-rma |
| 106 | +RESTART_VM: |
| 107 | + equivalenceGroup: restart # must be a defined action with no impactedEntityScope |
| 108 | +``` |
| 109 | + |
| 110 | +--- |
| 111 | + |
| 112 | +## 4. Walkthrough — custom CR plugin (step by step) |
| 113 | + |
| 114 | +### Step 1 — Emit a custom action from a health event |
| 115 | + |
| 116 | +A health monitor must set both fields — the action type and the custom action name: |
| 117 | + |
| 118 | +```yaml |
| 119 | +recommendedAction: CUSTOM |
| 120 | +customRecommendedAction: hardware-rma |
| 121 | +``` |
| 122 | + |
| 123 | +> **Platform-connector overrides cannot configure custom actions.** Overrides remap |
| 124 | +> `recommendedAction` between built-in enum values only (`RESTART_VM`, `REPLACE_VM`, …) and do not |
| 125 | +> set `customRecommendedAction`. If an override sets `recommendedAction: CUSTOM`, the custom string |
| 126 | +> stays empty and fault-remediation skips remediation as an unsupported action. |
| 127 | + |
| 128 | +### Step 2 — Register the action in fault-remediation Helm values |
| 129 | + |
| 130 | +Add your action and its template to |
| 131 | +`distros/kubernetes/nvsentinel/charts/fault-remediation/values.yaml`: |
| 132 | + |
| 133 | +```yaml |
| 134 | +maintenance: |
| 135 | + actions: |
| 136 | + hardware-rma: |
| 137 | + apiGroup: remediation.example.com |
| 138 | + version: v1alpha1 |
| 139 | + kind: RemediationRequest |
| 140 | + scope: Namespaced |
| 141 | + namespace: remediation |
| 142 | + completeConditionType: Complete |
| 143 | + templateFileName: hardware-rma.yaml |
| 144 | + equivalenceGroup: external-rma |
| 145 | + templates: |
| 146 | + hardware-rma.yaml: | |
| 147 | + apiVersion: {{ .ApiGroup }}/{{ .Version }} |
| 148 | + kind: RemediationRequest |
| 149 | + metadata: |
| 150 | + name: maintenance-{{ .HealthEvent.NodeName }}-{{ .HealthEventID }} |
| 151 | + namespace: {{ .Namespace }} |
| 152 | + labels: |
| 153 | + app.kubernetes.io/managed-by: nvsentinel |
| 154 | + spec: |
| 155 | + nodeName: {{ .HealthEvent.NodeName }} |
| 156 | + action: hardware-rma |
| 157 | + healthEventId: {{ .HealthEventID }} |
| 158 | +``` |
| 159 | + |
| 160 | +Key fields: |
| 161 | + |
| 162 | +| Field | Purpose | |
| 163 | +|-------|---------| |
| 164 | +| `equivalenceGroup` | Base group name; combined with the impacted entity into the effective group used for dedup | |
| 165 | +| `completeConditionType` | Condition `type` fault-remediation waits for | |
| 166 | +| `supersedingEquivalenceGroups` | Optional — e.g. `restart` supersedes `external-rma` | |
| 167 | +| `impactedEntityScope` | Optional — per-entity (e.g. per-GPU) dedup; custom actions may set this | |
| 168 | +| `templates` | Go template rendered into the CR body | |
| 169 | + |
| 170 | +> **RBAC:** fault-remediation auto-generates RBAC from `maintenance.actions` (resource name = |
| 171 | +> lowercase `kind` + `s`). Use CRD kinds with regular plurals (`RemediationRequest` → |
| 172 | +> `remediationrequests`); irregular plurals (`Policy` → `policys`) break RBAC. |
| 173 | + |
| 174 | +### Step 3 — Template variables |
| 175 | + |
| 176 | +Available in every maintenance template: |
| 177 | + |
| 178 | +| Variable | Description | |
| 179 | +|----------|-------------| |
| 180 | +| `.NodeName` / `.HealthEvent.NodeName` | Target node | |
| 181 | +| `.HealthEventID` | Triggering event ID | |
| 182 | +| `.HealthEvent` | Full health event (all fields) | |
| 183 | +| `.RecommendedAction` | Numeric action code | |
| 184 | +| `.RecommendedActionName` | Resolved action name (`hardware-rma`, `RESTART_VM`, …) | |
| 185 | +| `.ImpactedEntityScopeValue` | GPU UUID for per-GPU actions | |
| 186 | +| `.ApiGroup`, `.Version`, `.Kind`, `.Namespace` | From the action config | |
| 187 | +| `.TraceID`, `.SpanID` | OpenTelemetry correlation (when tracing enabled) | |
| 188 | + |
| 189 | +### Step 4 — Build your controller |
| 190 | + |
| 191 | +Scaffold a controller that: |
| 192 | + |
| 193 | +1. Watches your CR kind (`RemediationRequest`). |
| 194 | +2. Executes the repair (call a CSP API, open a support ticket, run diagnostics, …). |
| 195 | +3. Patches the status condition: |
| 196 | + |
| 197 | +```yaml |
| 198 | +status: |
| 199 | + conditions: |
| 200 | + - type: Complete # must match completeConditionType |
| 201 | + status: "True" # "True" = done; "False" makes NVSentinel retry with a new CR |
| 202 | + reason: RepairSucceeded |
| 203 | + message: Node hardware repaired and validated |
| 204 | +``` |
| 205 | + |
| 206 | +**Golden rules** (same as any NVSentinel plugin): |
| 207 | + |
| 208 | +1. Reconcile is **idempotent** — a no-op once `Complete=True`. |
| 209 | +2. **Make non-idempotent repairs durably idempotent.** A controller restart (or a retry after a |
| 210 | + `False` condition, which spawns a fresh CR) can re-run your repair before `Complete=True` is |
| 211 | + persisted. Guard one-shot side effects (opening a ticket, calling a CSP API) with a durable key |
| 212 | + such as `spec.healthEventId` or a status phase, so the effect happens **at most once**. |
| 213 | +3. Only set `Complete=True` when the repair is **actually** done; requeue until then. Set |
| 214 | + `Complete=False` only when you want NVSentinel to retry with a new CR. |
| 215 | +4. One CR per health event — NVSentinel manages its lifecycle via the equivalence group. |
| 216 | + |
| 217 | +Build and push the controller image with Kubebuilder's Makefile, pointing `IMG` at a registry |
| 218 | +**your cluster can pull from**: |
| 219 | + |
| 220 | +```bash |
| 221 | +docker login # once, to authenticate |
| 222 | +
|
| 223 | +# Replace YOUR_USER and my-remediation with your Docker Hub user and image name. |
| 224 | +export IMG=docker.io/YOUR_USER/my-remediation:dev # or your cluster's private registry |
| 225 | +make docker-build docker-push IMG=$IMG |
| 226 | +make install && make deploy IMG=$IMG |
| 227 | +``` |
| 228 | + |
| 229 | +> Use a **public** repo so the cluster can pull without credentials. For a private registry (e.g. |
| 230 | +> NVCR), add the cluster's `imagePullSecret` to the controller Deployment via `config/`. |
| 231 | + |
| 232 | +For a full Kubebuilder scaffold, reconciler walkthrough, and one-shot AI prompt, see |
| 233 | +[Writing a Drain Plugin — Appendix: One-shot AI prompt](./writing-a-drain-plugin.md#appendix-one-shot-ai-prompt) |
| 234 | +— the pattern is the same (NVSentinel creates the CR and polls a status condition; your controller |
| 235 | +reconciles it and sets completion), even though the upstream stage is node-drainer instead of |
| 236 | +fault-remediation. |
| 237 | + |
| 238 | +For a full working example (custom health monitor, CRD, controller, and Helm values), see the |
| 239 | +[local custom remediation demo](../../demos/local-custom-remediation-demo/README.md) — a memory-pressure |
| 240 | +monitor and reclaim controller on a local KIND cluster (no GPU required). |
| 241 | + |
| 242 | +--- |
| 243 | + |
| 244 | +## Appendix: One-shot AI prompt |
| 245 | + |
| 246 | +Paste this to an AI coding agent. It is **self-contained**: the controller is a standalone Kubebuilder |
| 247 | +project in **any repository**; produce Helm values snippets for fault-remediation registration |
| 248 | +(separate from the controller repo). Replace the bracketed parts. |
| 249 | + |
| 250 | +```text |
| 251 | +Create a new NVSentinel custom remediation plugin named "[my-action]" (e.g. "hardware-rma") that |
| 252 | +runs [the repair, e.g. "opens a CSP support ticket and waits for hardware replacement"] after |
| 253 | +fault-remediation has cordoned and drained the node. Follow this spec exactly. |
| 254 | +
|
| 255 | +Architecture (direct CR plugin): |
| 256 | +- A health monitor emits recommendedAction=CUSTOM with customRecommendedAction="[my-action]". |
| 257 | +- fault-remediation looks up maintenance.actions["[my-action]"], renders a Go template into a |
| 258 | + Kubernetes CR, creates it, and polls status.conditions[] until completeConditionType is True. |
| 259 | +- Your controller watches that CR, performs the repair, and sets the completion condition. |
| 260 | +
|
| 261 | +Health event contract (emit from a health monitor): |
| 262 | +- Set BOTH recommendedAction=CUSTOM and customRecommendedAction="[my-action]" on the HealthEvent |
| 263 | + (protobuf field customRecommendedAction on datamodels.HealthEvent). |
| 264 | +- Platform-connector overrides cannot configure custom actions — they remap built-in |
| 265 | + recommendedAction enums only and do not set customRecommendedAction. |
| 266 | +
|
| 267 | +Controller (standalone Kubebuilder project, any repo): |
| 268 | +- Scaffold: kubebuilder init --domain example.com --repo github.com/<your-org>/[my-remediation] |
| 269 | + then kubebuilder create api --group remediation --version v1alpha1 --kind RemediationRequest |
| 270 | + --resource --controller. |
| 271 | +- Spec: nodeName (string), action (string), healthEventId (string) — minimal fields from the |
| 272 | + fault-remediation template below. |
| 273 | +- Status: conditions []metav1.Condition with a subresource. |
| 274 | +- Reconciler: idempotent; no-op once Complete=True; run the repair at most once (guard non-idempotent |
| 275 | + side effects with a durable key such as spec.healthEventId so a restart or retry cannot repeat them); |
| 276 | + patch status with Type="Complete", Status="True" when repaired (or "False" to make NVSentinel retry |
| 277 | + with a new CR) using append-or-update-by-type (meta.SetStatusCondition + Status().Update). |
| 278 | + RBAC: remediationrequests + status; add any API permissions your repair needs. |
| 279 | +- Build and push with the generated Dockerfile/Makefile, pointing IMG at a registry your cluster can |
| 280 | + pull from (e.g. docker.io/<your-user>/[my-remediation]:dev or a private registry such as NVCR). |
| 281 | + Run docker login once, then make docker-build docker-push IMG=$IMG. Use a public repo so the |
| 282 | + cluster can pull without credentials; for a private registry, add the cluster's imagePullSecret |
| 283 | + to the controller Deployment via config/. Deploy with make install && make deploy IMG=$IMG. |
| 284 | +
|
| 285 | +fault-remediation registration (Helm values snippet — merge into your NVSentinel release's |
| 286 | +fault-remediation chart values; exact file/chart path depends on your deployment): |
| 287 | +- Add under maintenance.actions: |
| 288 | + [my-action]: |
| 289 | + apiGroup: remediation.example.com |
| 290 | + version: v1alpha1 |
| 291 | + kind: RemediationRequest |
| 292 | + scope: Namespaced |
| 293 | + namespace: remediation |
| 294 | + completeConditionType: Complete |
| 295 | + templateFileName: [my-action].yaml |
| 296 | + equivalenceGroup: [my-equivalence-group] |
| 297 | +- Add under maintenance.templates.[my-action].yaml a Go text/template body: |
| 298 | + apiVersion: {{ .ApiGroup }}/{{ .Version }} |
| 299 | + kind: RemediationRequest |
| 300 | + metadata: |
| 301 | + name: maintenance-{{ .HealthEvent.NodeName }}-{{ .HealthEventID }} |
| 302 | + namespace: {{ .Namespace }} |
| 303 | + labels: |
| 304 | + app.kubernetes.io/managed-by: nvsentinel |
| 305 | + spec: |
| 306 | + nodeName: {{ .HealthEvent.NodeName }} |
| 307 | + action: [my-action] |
| 308 | + healthEventId: {{ .HealthEventID }} |
| 309 | +- Template rules: plain Go text/template ONLY (no Sprig — no quote, trunc, or hash helpers). |
| 310 | + Use full {{ .HealthEvent.NodeName }}-{{ .HealthEventID }} for metadata.name. Do not template |
| 311 | + free-form message text into YAML. |
| 312 | +- Use a CRD kind with a regular plural (RemediationRequest -> remediationrequests); irregular |
| 313 | + plurals break fault-remediation's auto-generated RBAC. |
| 314 | +- Deploy the values via your usual NVSentinel Helm upgrade (e.g. helm upgrade <release> |
| 315 | + <chart> -n nvsentinel --reuse-values -f <overrides>.yaml), then wait for the fault-remediation |
| 316 | + Deployment rollout. |
| 317 | +
|
| 318 | +Verification (against a running NVSentinel cluster): |
| 319 | +- Trigger a health event with CUSTOM + customRecommendedAction="[my-action]" on a test node. |
| 320 | +- Confirm fault-remediation created the RemediationRequest (kubectl get remediationrequests -n |
| 321 | + remediation). |
| 322 | +- Confirm your controller reconciles it and sets status.conditions Type=Complete Status=True. |
| 323 | +- Confirm fault-remediation logs show the repair as complete and does not create a duplicate CR. |
| 324 | +
|
| 325 | +Ensure controller `go mod tidy` and `go build ./...` pass. Do not run docker push, helm upgrade, |
| 326 | +or kubectl apply unless credentials/cluster are available — but do create the Dockerfile, CRD, |
| 327 | +controller, and Helm values snippets. |
| 328 | +``` |
0 commit comments