Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This directory contains user and operator documentation for NodeWright. Here you
- [Quickstart](getting-started/quickstart.md): Install NodeWright and run a hello-world package on one node.
- [Installation](getting-started/installation.md): Install NodeWright via Helm.
- [Migration from Skyhook](getting-started/migration.md): Transition guide from Skyhook to NodeWright.
- [Examples](../examples/README.md): Runnable manifests for dependencies, uninstall, ordering, secrets, runtime-required, deployment policies, and interrupts.

## Architecture

Expand Down
32 changes: 23 additions & 9 deletions docs/getting-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,26 @@ spec:
nodewright.nvidia.com/quickstart: "true"
packages:
hello-world:
version: 1.1.0
image: ghcr.io/nvidia/skyhook-packages/shellscript
version: 1.1.2
image: ghcr.io/nvidia/nodewright-packages/shellscript
configMap:
config.yaml: |-
# Stages run in order: apply first, then config. Each stage runs its
# script, then its _check script, and the stage is only done once the
# check passes.
apply.sh: |-
#!/bin/bash
sleep 1
echo "Hello, apply!"
apply_check.sh: |-
#!/bin/bash
sleep 1
echo "Hello, apply check!"
# Runs only after apply and apply_check have succeeded.
config.sh: |-
#!/bin/bash
sleep 1
echo "Hello, config!"
config_check.yaml: |-
config_check.sh: |-
#!/bin/bash
sleep 1
echo "Hello, config check!"
Expand Down Expand Up @@ -135,7 +147,7 @@ Four parts of that manifest did all the work. Every field is covered in
| `nodeSelectors` | Chose which nodes to act on. Empty means *all* nodes — always set it deliberately. |
| `packages` | The unit of work. The map key (`hello-world`) is the package name. |
| `image` + `version` | `version` is the tag **and** the operator's ordering key, so it must be semver and the tag must not be inlined into `image`. |
| `configMap` | Configuration handed to the package. The `shellscript` package treats these keys as scripts, which is why no image build was needed. |
| `configMap` | Configuration handed to the package. The `shellscript` package runs these keys as scripts, which is why no image build was needed. Its key names are fixed (`apply.sh`, `config.sh`, and so on); a key it does not recognise is skipped without an error. |

Two knobs you did not set are worth knowing before you go near a real cluster:
`interruptionBudget` defaults to **100%** — every matching node at once — and
Expand All @@ -148,13 +160,15 @@ it declared no `interrupt`, it stopped there and the node was never cordoned or
drained. [Lifecycle of a NodeWright](../architecture/lifecycle.md) has the whole
picture.

The part worth internalising is what your two configMap keys were:
The part worth internalising is what the `config` pair of configMap keys were:

```text
config.yaml → the config stage's WORK step
config_check.yaml → the config stage's CHECK step
config.sh → the config stage's WORK step
config_check.sh → the config stage's CHECK step
```

`apply.sh` and `apply_check.sh` were the same pairing for the apply stage.

**Every work step has a paired check step, and the stage is not done until the
check passes.** That is not a naming convention — the operator runs them as
consecutive init containers in the same pod, so Kubernetes itself stops at the
Expand Down Expand Up @@ -185,4 +199,4 @@ helm uninstall nodewright --namespace nodewright
- [Lifecycle of a NodeWright](../architecture/lifecycle.md) — how a rollout actually runs
- [Interrupt Flow](../architecture/interrupt-flow.md) — cordon, drain, reboot
- [Deployment Policy](../user-guide/deployment-policy.md) — shaping a rollout across many nodes
- [`examples/`](../../examples/) — including an interrupt example with real workloads
- [`examples/`](../../examples/) — runnable examples for dependencies, uninstall, ordering, secrets, runtime-required, rollouts, and interrupts
88 changes: 77 additions & 11 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,82 @@
# NodeWright Examples

This directory contains sample manifests and usage patterns for NodeWright. Use these examples to learn how to configure, test, and extend NodeWright in your own clusters.
Manifests you can apply to a cluster to see NodeWright do something specific. Each
directory has its own README with the commands to run and what you should see.

## Included Examples
If you have not used NodeWright before, start with [`simple/`](simple/), then work
down the table.

- `debug_pod.yaml`: Example manifest for launching a debug pod in your cluster.
- Replace the `{{ set hostname }}` with the hostname you want to put the debug pod onto
- `kubectl apply -f examples/debug_pod.yaml`
- Once launched do `kubectl exec -ti debug-pod sh -c 'chroot /host bash'`
- You will then be on the host as root
- Once done, exit and run `kubectl delete po/debug-pod` to remove the pod
- `simple/`: The hello-world NodeWright used by the [quickstart](../docs/getting-started/quickstart.md).
- `interrupt-wait-for-pod/`: Example showing how to use interrupts and wait-for-pod logic with NodeWright.
| Example | What it shows | Where to run it |
|---|---|---|
| [`simple/`](simple/) | The hello world: one package, no interrupt. Used by the [quickstart](../docs/getting-started/quickstart.md). | Any cluster |
| [`dependencies/`](dependencies/) | `dependsOn`: a diamond of packages, with the run order read back from the host | Any cluster |
| [`uninstall/`](uninstall/) | Reversing a package: `uninstall.enabled` and `uninstall.apply`, cancelling, and delete-time cleanup | Any cluster |
| [`ordering/`](ordering/) | `priority`, `sequencing: all` as a cluster-wide barrier, and `pause` as a brake | Any cluster |
| [`secrets/`](secrets/) | Passing a Kubernetes Secret to a package through `env`, without logging it | Any cluster |
| [`runtime-required/`](runtime-required/) | Gating workloads behind a node being ready, with the runtime-required taint | Any cluster |
| [`rollout/`](rollout/) | A `DeploymentPolicy`: canary, production, and default compartments, and a stopped compartment | Six or more workers; a kind config is included |
| [`interrupt-wait-for-pod/`](interrupt-wait-for-pod/) | `podNonInterruptLabels`: a reboot interrupt that waits for your workload to finish | Three nodes, EKS node group labels |
| [`config-interrupts/`](config-interrupts/) | Matching the interrupt to the change with `configInterrupts`, using the real `tuning` package | Disposable real nodes |
| [`copy-fail-mitigation/`](copy-fail-mitigation/) | A real package from the catalog, rolled out the production way | Real nodes |
| [`debug_pod.yaml`](debug_pod.yaml) | A privileged pod with the node's filesystem mounted, for looking at what a package did | Any cluster |

Feel free to use, modify, or extend these examples for your own use cases!
## Conventions

- **Nothing happens until you opt a node in.** Every example selects nodes by the
label `nodewright.nvidia.com/example=<name>`, which no node carries by default.
A NodeWright with an empty `nodeSelectors` matches *every* node in the cluster, so
these examples never rely on that. Label one node and the blast radius is one node.
- **Packages come from [`nodewright-packages`](https://github.com/NVIDIA/nodewright-packages).**
Most examples use the `shellscript` package, which runs the scripts you put in
`configMap`. Its script names are fixed: `apply.sh`, `apply_check.sh`, `config.sh`,
`config_check.sh`, `post_interrupt.sh`, `post_interrupt_check.sh`, `uninstall.sh`,
and `uninstall_check.sh`. A script that is missing is skipped with a message and
counts as success, so a misspelled key does nothing rather than failing.
- **Every step has a check.** A stage is done when its `*_check` script passes, not
when its work script exits.
- **Scripts run on the host**, as root, in a chroot of the node's filesystem. The
examples write only under `/etc/nodewright-example/` so cleanup is one `rm -rf`.
- **Clean up after yourself.** Deleting a NodeWright does not undo what its packages
did to a host. Each README ends with a clean-up section, and says what stays behind.

The commands assume the operator is installed in the `nodewright` namespace, the
default for new installs. Installs that predate the rename are in `skyhook`.

## Reading files on a node

Several examples write a file to the host. To read it, run a debug pod pinned to the
node:

```bash
NODE=<node-name>
sed "s/{{ some hostname }}/$NODE/" examples/debug_pod.yaml | kubectl apply -f -
kubectl exec -ti debug-pod -- chroot /host cat /etc/nodewright-example/<path>
kubectl exec -ti debug-pod -- chroot /host bash # or an interactive root shell
kubectl delete pod debug-pod
```

The pod is privileged and mounts the whole host filesystem. Delete it when you are done.

## Reading what NodeWright did

```bash
kubectl get nodewright <name> -w # overall status
kubectl nodewright node status <node> # every NodeWright on a node
kubectl nodewright package status <pkg> --nodewright <name> -o wide
kubectl nodewright package logs <pkg> --nodewright <name> --stage apply
kubectl get nodewright <name> -o jsonpath='{.status.conditions}' # why nothing is happening
```

The authoritative record of each package is an annotation on the node,
`nodewright.nvidia.com/nodeState_<name>`, not the NodeWright's status. Status,
state, and stage are three different things; see
[Operator Status](../docs/architecture/operator-status.md).

## Adding an example

Keep it to one idea, safe to apply to a single node, and honest about what it leaves
on the host. Use the opt-in label convention above, give every YAML file the
license header (`make fmt` adds it), and link the docs page that explains the
feature. Check it with `kubectl apply --dry-run=server -f` against a cluster that has the
operator installed, so the admission webhook validates it: an example the webhook
rejects is worse than none.
92 changes: 92 additions & 0 deletions examples/config-interrupts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Example: matching the interrupt to the change

An interrupt is the disruptive step after a package applies: a service restart or
a reboot. It means the node is cordoned and drained first, so you want the
smallest one that does the job.

This example uses the real [`tuning`](https://github.com/NVIDIA/nodewright-packages/tree/main/tuning)
package to change kernel and ulimit settings, and gives each setting the interrupt
it actually needs:

| configMap key | Changes | Interrupt |
|---|---|---|
| `sysctl.conf` | kernel parameters | `restartAllServices` |
| `ulimit.conf` | per-process limits | none: no `configInterrupts` entry |
| `grub.conf` (commented out) | kernel command line | `reboot` |

`interrupt` is what runs when the package is first applied. `configInterrupts`
takes over for later edits, and runs only the interrupt for the key that changed.

> **This changes real host settings and restarts services.** Use disposable nodes.

## Run it

```bash
NODE=<node-name>
kubectl label node "$NODE" nodewright.nvidia.com/example=config-interrupts
kubectl apply -f examples/config-interrupts/nodewright.yaml
kubectl get nodewright config-interrupts-demo -w
```

Because the package declares an interrupt, the node is cordoned and drained
before it runs. In another terminal:

```bash
kubectl get node "$NODE" -w
kubectl nodewright package status tuning --nodewright config-interrupts-demo -o wide
```

You will see the package move through `apply`, `config`, `interrupt`, and
`post-interrupt`, and the node uncordon when it finishes.

## Change one setting at a time

**Edit `ulimit.conf`.** Change `nofile=1048576` to another value and re-apply. The
package re-runs `config` and finishes. The node is never cordoned or drained,
because no `configInterrupts` entry matches `ulimit.conf`.

```bash
kubectl patch nodewright config-interrupts-demo --type=merge \
-p '{"spec":{"packages":{"tuning":{"configMap":{"ulimit.conf":"nofile=524288"}}}}}'
```

**Edit `sysctl.conf`.** Change `vm.max_map_count`. This time the node is cordoned,
drained, and services are restarted, but it is not rebooted.

**Add `grub.conf`.** Uncomment `grub.conf` in both places and re-apply. Now the node
reboots.

> A `reboot` anywhere wins. If several packages become due on one node in the same
> pass, their interrupts merge into one, ranked `reboot` over `restartAllServices`
> over `service` over `noop`. The node reboots once instead of restarting services
> first.

## Related knobs, set in the manifest

- `interruptionBudget.count: 1` means only one node is ever cordoned and drained at
a time.
- `drainConfig.timeout` bounds a drain that would otherwise wait forever on a pod
that will not leave. When it expires the node goes `erroring`.
- To keep specific pods from being evicted, see
[`interrupt-wait-for-pod`](../interrupt-wait-for-pod/demo.md).

## Clean up

`tuning` writes drop-in files, so it can take them out again. Uninstall first, then
delete, otherwise the settings stay on the host:

```bash
kubectl patch nodewright config-interrupts-demo --type=merge \
-p '{"spec":{"packages":{"tuning":{"uninstall":{"apply":true}}}}}'
kubectl get nodewright config-interrupts-demo -w # wait for complete
kubectl delete -f examples/config-interrupts/nodewright.yaml
kubectl label node "$NODE" nodewright.nvidia.com/example-
```

See the [`uninstall`](../uninstall/README.md) example for how that workflow works.

## Related

- [Interrupt Flow](../../docs/architecture/interrupt-flow.md): cordon, drain,
interrupt, uncordon, in full
- [The NodeWright Custom Resource: `configInterrupts`](../../docs/user-guide/custom-resource.md#configmap-and-configinterrupts)
70 changes: 70 additions & 0 deletions examples/config-interrupts/nodewright.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Matches the interrupt to the change, using the `tuning` package.
#
# - `interrupt` is what runs when the package is first applied.
# - `configInterrupts` is what runs when one specific configMap key changes
# afterwards. A key with no entry (ulimit.conf here) changes with no interrupt
# at all, so the node is never cordoned or drained for it.
#
# This changes real host settings and restarts services. Use disposable nodes.
#
# Opt a node in with:
# kubectl label node <node> nodewright.nvidia.com/example=config-interrupts

apiVersion: nodewright.nvidia.com/v1alpha1
kind: NodeWright
metadata:
name: config-interrupts-demo
spec:
nodeSelectors:
matchLabels:
nodewright.nvidia.com/example: config-interrupts

# One node at a time: an interrupt cordons and drains its node.
interruptionBudget:
count: 1

# Give up on a drain that is stuck instead of waiting forever. On timeout the
# node goes `erroring` and the package does not run on it.
drainConfig:
timeout: 10m

packages:
tuning:
version: 1.1.7
image: ghcr.io/nvidia/nodewright-packages/tuning
interrupt:
type: restartAllServices
# tuning writes drop-in files, so it can remove them again; see README.md.
uninstall:
enabled: true
apply: false
configInterrupts:
sysctl.conf:
type: restartAllServices
# Uncomment grub.conf below, and this entry, to see a reboot interrupt.
# grub.conf:
# type: reboot
configMap:
sysctl.conf: |-
vm.max_map_count=262144
fs.inotify.max_user_watches=524288
ulimit.conf: |-
nofile=1048576
# grub.conf: |-
# hugepagesz=2M
# hugepages=1024
66 changes: 66 additions & 0 deletions examples/copy-fail-mitigation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Example: rolling out a real package

The other examples use throwaway scripts. This one applies a package from the
[NodeWright package catalog](https://github.com/NVIDIA/nodewright-packages),
`copy-fail`, the way you would roll it out for real.

`copy-fail` is the temporary host mitigation for CVE-2026-31431. It blacklists the
`algif_aead` kernel module through `/etc/modprobe.d/disable-algif.conf` and makes a
best-effort `rmmod`. It does not reboot the node. See the
[package README](https://github.com/NVIDIA/nodewright-packages/tree/main/copy-fail)
for what it does and does not cover.

What makes the manifest production-shaped rather than a demo:

- **Selection is deliberate.** It names its nodes with a label and excludes the
control plane with a `matchExpressions` rule. An empty `nodeSelectors` would match
every node.
- **The rollout is bounded.** `interruptionBudget.percent: 25` caps how many nodes
run at once. With no interrupt nothing is drained, but an unset budget still means
100%.
- **There is a way back.** `uninstall.enabled: true` lets you remove the
mitigation by flipping `apply` once your distribution ships a fixed kernel.

## Run it

Start on one node:

```bash
NODE=<node-name>
kubectl label node "$NODE" nodewright.nvidia.com/example=copy-fail
kubectl apply -f examples/copy-fail-mitigation/nodewright.yaml
kubectl get nodewright copy-fail-mitigation -w
```

Check the result on the node. From a [debug pod](../README.md#reading-files-on-a-node):

```bash
chroot /host cat /etc/modprobe.d/disable-algif.conf
chroot /host sh -c 'lsmod | grep algif_aead || echo "algif_aead not loaded"'
```

If `config-check` fails because the module is still in use, the node reads
`erroring` until it reboots. That is the package being honest that the node is
still exposed. Reboot on your own schedule, or set `ALLOW_LOADED_MODULE` (commented
in the manifest) to accept the risk until then.

Widen the rollout by labelling more nodes, or replace the label selector with one
that matches the fleet.

## Retire it

When fixed kernels are deployed:

```bash
kubectl patch nodewright copy-fail-mitigation --type=merge \
-p '{"spec":{"packages":{"copy-fail":{"uninstall":{"apply":true}}}}}'
```

Wait for `complete`, then delete the NodeWright. See the
[`uninstall`](../uninstall/README.md) example.

## On read-only host filesystems

On nodes with a read-only root such as GKE Container-Optimized OS, the operator
needs a writable directory for the agent. The package README covers the Helm
values (`copyDirRoot`, `reapplyOnReboot`).
Loading
Loading