CLAUDE.md / AGENTS.md
April 23, 2026 · View on GitHub
This file provides guidance to coding agents (Claude Code, Cursor, Codex, etc.) when working with code in this repository.
The canonical file lives at .claude/CLAUDE.md. The root-level AGENTS.md is a symlink to this file so agents that expect the cross-agent AGENTS.md convention find the same content.
Repository overview
Skyhook (being renamed to NodeWright) is a Kubernetes-aware package manager for safely modifying host infrastructure at scale. It coordinates the node lifecycle (cordon → drain → apply package → interrupt/reboot → uncordon) as controlled rollouts gated by interruption budgets and deployment policies.
Rename status: the project is transitioning from Skyhook → NodeWright. The namespace (skyhook) and the agent image (ghcr.io/nvidia/skyhook/agent) still use skyhook pending their migration. Components already moved to nodewright: the Go module (github.com/NVIDIA/nodewright/operator), the Helm chart (name: nodewright, distributed at oci://ghcr.io/nvidia/nodewright/charts/nodewright), the operator image (ghcr.io/nvidia/nodewright/operator), the primary CRD group (nodewright.nvidia.com/v1alpha1 NodeWright; the legacy skyhook.nvidia.com group is kept read-only for a migration window), and the CLI plugin (kubectl nodewright, binary kubectl-nodewright). Don't "fix" nodewright references back to skyhook, and don't preemptively rename what hasn't moved yet.
Required reading: docs/ (load every session)
docs/ is the authoritative source for domain concepts and behavioral contracts — most traps in this codebase are explained there, not in code comments. At the start of every session, before writing any code in this repo, read all of the files below into context. The whole set is ~2000 lines / ~100KB — negligible for modern context windows, and skipping it almost guarantees a wrong-shaped PR that violates an unstated contract.
Required (read on session start):
docs/README.md— docs indexdocs/operator-status-definitions.md— Status / State / Stage vocabulary (distinct concepts; conflating them causes subtle bugs)docs/interrupt_flow.md— cordon / drain / interrupt / uncordon sequence andpodNonInterruptLabelssemanticsdocs/runtime_required.md— runtime-required taint flow andAutoTaintNewNodesdocs/ordering_of_skyhooks.md— priority, sequencing (nodevsall),SKYHOOK_NODE_ORDERdocs/deployment_policy.md— cluster-scoped rollout shapingdocs/resource_management.md— strict resource-override validation (all 4 fields or none)docs/versioning.md— per-component semver, strictly enforceddocs/taints.md— taint semanticsdocs/providing_secrets_to_packages.md— how packages consume secretsdocs/kubernetes-support.md— supported K8s versionsdocs/operator_resources_at_scale.md— scale characteristicsdocs/release-process.md,docs/releases.md— release process and historydocs/cli.md— CLI reference (needed before touchingoperator/cmd/cli/)
Read on demand (not required up-front):
docs/designs/— design docs for existing features (consult before changing an existing feature)docs/plans/— in-flight plans (check before starting new work in an area)docs/kyverno/,docs/metrics/— policy and observability surfaces
If a doc above is silent on a question you need to answer, say so explicitly rather than guess.
Three components, three toolchains
operator/— Go controller-manager (controller-runtime, Kubebuilder v4). Go 1.26.5, vendored (GOFLAGS=-mod=vendor). Also hosts the CLI (cmd/cli) built as a kubectl plugin.agent/skyhook-agent/— Python 3.10+ package (hatch-managed). Runs inside every package container; reads/skyhook-package/config.jsonand executes lifecycle steps (apply / config / interrupt / post-interrupt / upgrade / uninstall). Tests via pytest, vendored deps underagent/vendor/.chart/— Helm chart. Generated fromoperator/config/viahelmify(make generate-helm) but hand-edited after; don't regenerate blindly.
The root Makefile just fans out into operator/ and agent/ subdirectories. Most real targets live in operator/Makefile.
Common commands
All operator commands run from operator/:
make build # build manager + CLI (also runs manifests/generate/fmt/vet/lint)
make build-manager # operator binary only → bin/manager
make build-cli # kubectl-nodewright → bin/nodewright
make unit-tests # ginkgo unit tests + envtest (fake apiserver), writes to reporting/
make test # full suite: manifests, generate, fmt, vet, lint, unit + e2e + cli-e2e + helm + operator-agent
make e2e-tests # chainsaw e2e against current cluster (set POOL=<name> to run one pool — see docs/ci-test-pools.md)
make watch-tests # ginkgo watch mode
make run # runs controller as background process against current kubeconfig (ENABLE_WEBHOOKS=false by default)
make kill # stops the backgrounded manager
make manifests # regenerate CRDs/RBAC/webhooks from kubebuilder markers — REQUIRED after editing api/
make generate # regenerate zz_generated.deepcopy.go — REQUIRED after editing api/ types
make generate-mocks # regenerate mockery mocks — REQUIRED after editing mocked interfaces
make fmt # gofmt + license headers
make lint # golangci-lint + license-check
make lint-fix # golangci-lint --fix
make install # apply CRDs to current cluster (kustomize config/crd)
make uninstall # remove CRDs
make create-kind-cluster # fresh kind cluster with docker creds wired up
make create-deployment-policy-cluster # 15-node kind cluster, needed for deployment-policy e2e
Run a single Go test with the Ginkgo focus flag (tests use Ginkgo/Gomega, not stdlib testing.T):
cd operator
./bin/ginkgo run --focus "my describe text" ./internal/controller/...
# or for packages with stdlib Go tests:
go test -mod=vendor -run TestName ./internal/graph/...
Agent commands run from agent/:
make venv # one-time: create ./venv and install hatch
make test # hatch test with coverage
make build # hatch build → dist/
E2E tests use chainsaw against a real cluster, driven from k8s-tests/chainsaw/{skyhook,cli,helm,deployment-policy}. They require a kind cluster set up via make create-kind-cluster (or the 15-node variant for deployment-policy). operator-agent-tests additionally requires AGENT_IMAGE=… to be set.
Architecture
CRDs (see operator/api/v1alpha1/)
- Skyhook (namespaced) — desired state: a DAG of
packages(container image + version + configMap + optionaldependsOn), node selector, interruption budget, additional tolerations, runtime-required flag, priority/sequencing, optionalDeploymentPolicyreference. - DeploymentPolicy (cluster-scoped) — rollout shape: batch sizing, pause/resume windows, cross-Skyhook ordering.
CRD types are kubebuilder-annotated (//+kubebuilder:…). Any change to these files must be followed by make manifests generate — the generated zz_generated.deepcopy.go, CRD YAML under config/crd/bases/, and webhook config are all consumed at build time.
Controller layout (operator/internal/)
controller/skyhook_controller.go— primary reconcile loop (~2500 lines); owns Skyhook lifecycle, package-pod orchestration per node, interrupt/reboot flow, finalizer handling.controller/cluster_state_v2.go— cluster-state snapshot used by reconcile (what packages exist on which nodes, what stage each is in). This is the "v2" state model; there's no v1 anymore, the name is historical.controller/pod_controller.go— watches package pods and maps their completion back to node state annotations.controller/webhook_controller.go— self-managed webhook certs + validating/mutating webhook wiring.controller/event_handler.go— event filters deciding when SCR/node/pod changes enqueue a reconcile.wrapper/— higher-level domain objects (skyhook.go,node.go,compartment.go) that wrap the raw CRs to expose intent (e.g., "does this node still need interrupt?"). Controllers work through these wrappers rather than raw types.graph/dependency_graph.go— topological ordering of packages based ondependsOn.dal/— data-access layer abstraction over controller-runtime client (makes mocking in tests easier).zz.migration.*.go(e.g.,controller/zz.migration.0.5.0.go,wrapper/zz.migration.0.5.0.go) — hand-written one-shot migration shims that upgrade existing CRs from an older schema. Despite thezz.prefix these are not generated bymake generate— don't delete them, don't attempt to regenerate them, and don't move them just because the filename sorts oddly. If you need a new migration, add a newzz.migration.<version>.goalongside the existing ones.
Status / State / Stage (vocabulary — don't conflate)
Three distinct concepts, intentionally named to look similar. Mixing them up causes subtle bugs. Full definitions in docs/operator-status-definitions.md; summary:
- Status — high-level health of a Skyhook or a node (e.g.,
complete,blocked,waiting,paused,disabled,in_progress,erroring,unknown). Derived from the collective States of the node's packages. - State — per-package execution outcome on a node (
complete,in_progress,skipped,erroring,unknown). - Stage — which lifecycle phase a package is currently in (
uninstall,upgrade,apply,config,interrupt,post-interrupt, each with its*-checkcounterpart).
In prose, code, and logs: Status describes the Skyhook or node as a whole, State describes one package on one node, Stage describes which phase of that package is running. A field, variable, or log key named for one of the three must exclusively carry that concept.
Package lifecycle stages
Packages run through stages in this order (from README.md §Stages):
- Without interrupts: Uninstall → Apply → Config (or Upgrade → Config on version bump)
- With interrupts: add Interrupt → Post-Interrupt at the end, with the node cordoned and drained before Interrupt.
Semantic versioning is strictly enforced so the operator can detect upgrade vs. downgrade vs. fresh-apply. State is persisted as annotations on the Node (skyhook.nvidia.com/nodeState_<skyhook-name>), not on the Skyhook CR.
Agent (Python)
The agent is a container entrypoint the operator injects alongside every package. It:
- Reads
/skyhook-package/config.json(validated againstschemas/) - Dispatches to the requested stage/step
- Uses
chroot_exec.pyto run step scripts inside the host root mount - Writes completion flag files so subsequent stages skip already-done work
- Gates interrupt re-runs on
SKYHOOK_RESOURCE_ID(unique per package config)
Relevant env vars are documented in agent/README.md.
CLI (operator/cmd/cli/)
kubectl plugin (kubectl nodewright …). Subcommands under app/: node, package, deploymentpolicy, plus reset, lifecycle (pause/resume/disable/enable), version, migrate. Commands go through operator/internal/cli/client rather than talking to the apiserver directly. Recent operators use annotations (nodewright.nvidia.com/pause, nodewright.nvidia.com/disable) for pause/disable — not spec fields (the legacy Skyhook uses the skyhook.nvidia.com/* equivalents).
Code style (Go)
Working rules
- Read before writing — never edit a file you haven't read.
- Tests must pass —
make unit-tests(or fullmake test) before considering a change done; don't skip or disable tests to make CI green. - Use project patterns; justify new ones in the PR description. Before writing code, find the nearest existing example in the same package and match it — error-wrapping style,
dal/wrapperindirection, Ginkgo spec structure, controller-runtime idioms, constant placement, etc. If the pattern you're about to introduce doesn't already exist in the target package, stop and ask whether it should — and if you proceed, call it out explicitly in the PR description (what you introduced, why the existing patterns didn't fit, and why the new one should become the next convention). Silent divergence is rejected in review. - 3-strike rule — after three failed attempts at the same fix, stop and reassess rather than piling on more code.
- Don't add features that weren't asked for — no preemptive abstractions, config knobs, or scope creep.
- Prefer
EditoverWrite— only create new files when the code genuinely doesn't belong in an existing one. - Prefer the Makefile over raw commands.
make build/make test/make fmt/make lint/make manifests generate/make runencode-mod=vendor, license-header formatting, envtest + ginkgo setup, CRD/deepcopy/mock generation sequencing, logging / PID / reporting paths, and more — rawgo build/go test/golangci-lint runinvocations routinely skip some of these and produce drift. Runmake help(from repo root oroperator/) to see available targets before reaching for a raw command. Drop to a raw command only for narrow cases the Makefile doesn't cover — e.g.,ginkgo --focus "text"for a single spec, orgo test -run TestNamefor a single stdlib test. If a workflow isn't in the Makefile and you'll repeat it, add a target rather than documenting the raw command.
Errors
- Skyhook uses stdlib
errors+fmt.Errorf("…: %w", err)(nopkg/errorssentinel-code wrapper). When you wrap, use%wsoerrors.Is/errors.Ascontinue to work. - Never return a bare
errfrom a function that does more than one thing — add context:fmt.Errorf("reconciling node %s: %w", node.Name, err). - Don't ignore
Close()on writable handles.errcheckis on; check the error onos.Create,os.OpenFile(..., O_RDWR|O_WRONLY), etc. Capture it:closeErr := f.Close() if err == nil { err = closeErr } - K8s helpers:
client.IgnoreNotFound(err)andcontrollerutil.SetControllerReferencereturns are idiomatic and don't need wrapping.
Logging
Skyhook uses controller-runtime's logr.Logger (obtained via log.FromContext(ctx) or the Logger field on the reconciler). Do not introduce slog or log (stdlib) for new code — stay consistent with the surrounding controller. Log with key/value pairs:
log := log.FromContext(ctx)
log.Info("reconciling skyhook", "name", sh.Name, "generation", sh.Generation)
log.Error(err, "failed to cordon node", "node", node.Name)
No fmt.Println / fmt.Printf in non-test code (already the convention — grep confirms zero occurrences).
Context and concurrency
- Plumb the reconcile
ctxthrough; don't createcontext.Background()inside the reconcile path.context.Background()is fine for: packageinit, long-running background goroutines owned bymain, and test setup. - Any HTTP / external I/O needs a bounded context:
ctx, cancel := context.WithTimeout(ctx, …); defer cancel(). - Never use
http.DefaultClient(zero timeout). Build a client withTimeout:set, or use the rest client wired up by controller-runtime. - Check
ctx.Done()in long-running loops so reconcile cancellation propagates. - For parallel fan-out, prefer
golang.org/x/sync/errgroup(already ingo.sum) over manualsync.WaitGroup.
Operator / reconcile pattern
Skyhook is a Kubernetes operator. Internalize these before touching the controller:
- Level-triggered, not edge-triggered. Reconcile must be correct given only the current state of the world. Never assume a particular event caused this invocation, never assume the previous reconcile ran, never assume you'll be called again "soon" unless you explicitly requeue. If a node's annotation is missing, figure out why from the spec + cluster state — don't try to remember what you did last time.
- Idempotent. Running reconcile twice with identical inputs must produce the same result and no double side-effects. Before creating a pod, drain, taint, or annotation, check if it already exists. Before mutating status, compare to the observed value.
- Desired state (spec) vs. observed state (cluster). The reconciler's one job is to converge observed → desired. Intent lives in
SkyhookSpec/DeploymentPolicySpec; observation lives in the cluster (Nodes, Pods, annotations) and is summarized bycluster_state_v2.go. Status is an observation the operator publishes — never read your own status back as authoritative state; re-derive from the cluster. - No reconciler-owned state. Don't cache progress on the
SkyhookReconcilerstruct between reconciles — pods restart, leadership changes, memory vanishes. Persist anything that must survive to the apiserver (annotations on the Node, status subresource, finalizers). - Requeue, don't loop. Need to re-check in 30s? Return
ctrl.Result{RequeueAfter: 30*time.Second}. Need to retry on error? Return the error. Don'ttime.Sleepinside reconcile. - Finalizers for cleanup. Anything the operator creates outside the owner-reference tree (host-side mutations, taints, annotations on Node objects) must be reversed via the
skyhook.nvidia.com/skyhookfinalizer. The finalizer block inskyhook_controller.gois the template. - Owner references for K8s-native cleanup. Pods, ConfigMaps, etc. that belong to a Skyhook CR should have it as their owner reference so they GC automatically.
controllerutil.SetControllerReferenceis the right call. - Status writes go through the subresource. Use
.Status().Update()/.Status().Patch(), not a plainUpdate(), so status and spec updates don't race the webhook. - Errors escape to the queue. Returning an error from Reconcile triggers controller-runtime's exponential backoff requeue — that's the mechanism. Don't swallow errors to "avoid retries"; if an error is expected (e.g., transient conflict), log it and return
nilwith an explicit RequeueAfter.
Kubernetes patterns
- Watch, don't poll. controller-runtime gives you informers — use
ctrl.Watches/source.Kindand enqueue reconciles from events rather than loops withtime.Sleep/ticker. Interrupt-budget and backoff requeue are fine (ctrl.Result{RequeueAfter:…}), but a goroutine polling the apiserver is not. - Create-or-update for mutable resources.
IgnoreAlreadyExistssilently keeps stale state around. Usecontrollerutil.CreateOrUpdate(orCreateOrPatch) for ConfigMaps, RBAC, pods you re-generate.IgnoreAlreadyExistsis only appropriate for immutable kinds (Namespace, a fresh ServiceAccount you don't mutate). - Use the
dalandwrapperlayers. Don't reach forcontroller-runtimeclient.Clientdirectly from new controller code ifinternal/dalalready exposes the call — it's the seam tests mock against.
Constants
No pkg/defaults package here — but the principle still applies: extract repeated literals (timeouts, annotation keys, label values, image names) into named constants in the nearest appropriate file. The annotation and finalizer names already live in controller/annotations.go and controller/skyhook_controller.go; follow suit.
Comments
Default to no comments. Well-named identifiers, small functions, and idiomatic control flow should tell the story. Add a comment when — and only when — the code is doing something odd, surprising, or that breaks a pattern a reader would otherwise assume. In that case the comment must explain both:
- What is unusual (so the reader knows this isn't the pattern to copy), and
- Why it's done this way (the hidden constraint, subtle invariant, ordering dependency, known bug, webhook race, performance reason, upstream library quirk, etc.).
Examples of comments worth writing:
// Patch instead of Update here: the node's status may have been mutated
// by the kubelet between our Get and Update, and a full Update would
// stomp the kubelet's changes.
// Skip the finalizer wait on the DeploymentPolicy: cluster-scoped CRs
// have no owner references back to the operator deployment, so if the
// operator is being uninstalled first, nothing will ever release it.
// We intentionally do NOT cache this in the reconciler — leader-election
// rollover would keep stale values across pod restarts.
Examples to not write:
- Narration that restates the code:
// increment inext toi++. Delete. - PR / issue / fix references without substance:
// added for PR #123or// fix for the bug last week. These rot; put the PR description in the PR, and explain the reason in the comment (e.g., "apiserver returns empty status on first create; re-read once"). - TODOs without a name, date, or concrete trigger. If you must leave one, say who, when, and under what condition it should be revisited.
If you find yourself writing a long comment to explain a clever block, consider whether a rename, an extraction, or simply not being clever would remove the need for the comment altogether. Comments are a signal that the code couldn't explain itself — a last resort, not a decoration.
Tests
- Ginkgo/Gomega, not stdlib
t.Run— match the file you're adding to.suite_test.gounder each package sets up envtest. New spec blocks go under existingDescribewhere possible. Table-driven tests inside Ginkgo are fine (and common in this repo) — useDescribeTable/Entry, or just iterate a slice of cases inside anIt. The rule is "don't reach fort.Run", not "no tables". - Run a single describe with
ginkgo --focus "text". - Unit tests use envtest (a fake apiserver); e2e tests use chainsaw against a kind cluster. Don't mix the two — a test that needs real pods running belongs under
k8s-tests/chainsaw/, notinternal/controller. - For mocking, regenerate with
make generate-mocksafter editing an interface — hand-written mocks underinternal/controller/mock/will drift.
Anti-patterns
| Anti-pattern | Correct approach |
|---|---|
| Edit a file you haven't read | Read before Edit |
| Add flags / knobs / abstractions not requested | Build only what was asked |
| Invent a new error/logging/testing style | Match the nearest existing file |
Return bare err | Wrap with fmt.Errorf("…: %w", err) |
context.Background() inside reconcile | Pass ctx from Reconcile through |
http.DefaultClient | &http.Client{Timeout: …} |
| Polling loop against the apiserver | Watches + event-driven enqueue |
IgnoreAlreadyExists on a mutable resource | controllerutil.CreateOrUpdate |
| Magic literal timeouts / annotation keys scattered around | Named constant near the owner |
Ignore Close() on writable handles | Capture + check closeErr |
Skip make fmt before commit | License headers + gofmt are part of the gate |
Edit zz_generated.* or config/crd/bases/* by hand | Change the source, then make manifests generate |
| Comment that narrates what the code does | Delete it — or rename the code so it explains itself |
Odd / pattern-breaking code without a // why: comment | Add a comment that names the constraint or invariant forcing the deviation |
Decision framework
When choosing between approaches, prioritize:
- Testability — can it be covered by envtest without a real cluster?
- Readability — will the next person on pager-duty understand it at 3am?
- Consistency — does it match neighboring code?
- Simplicity — is it the smallest solution that works?
- Reversibility — can we back it out without a migration?
Design principles
- Partial failure is the steady state — nodes come and go, pods die, webhook certs rotate. Design for timeouts, bounded retries, and idempotent reconciles.
- Observability is mandatory — structured
logrmessages, metrics viacontroller/metrics.go, events via the event recorder. - Boring first — prefer proven controller-runtime idioms over clever abstractions.
Conventions and gotchas
- Vendoring: Go deps are vendored.
make build/make testpass-mod=vendorviaGOFLAGS;go getis still fine but follow withgo mod vendor. - License headers: every source file needs an Apache-2.0 header.
scripts/format_license.py(invoked bymake license-fmt, which runs as part ofmake fmt) adds/replaces them automatically — don't copy-paste headers by hand. - Commits: Conventional Commits format, with DCO sign-off (
git commit -s). No pseudonyms. - Docs are part of every PR. If your change alters user-visible behavior (CRD field, CLI command/flag, env var, annotation, metric, lifecycle semantics, install/upgrade flow) or an architectural concept captured in
docs/, update the affected doc page in the same PR — not a follow-up. If a doc page is wrong or stale because of your change, fix it. If your change makes an existing doc obsolete, delete the stale section rather than leaving two sources of truth. A behavior-changing PR without a correspondingdocs/update will be rejected in review — treat this as a blocking requirement, not a nicety. Docs-only changes are fine on their own, but code changes without docs are not. - Container runtime: the operator Makefile defaults to
podmanlocally,dockerin CI. Override viaDOCKER_CMD=docker. make runis a background process: it writes its PID toreporting/int/run.PID. Always pair withmake killbefore re-running; otherwise you'll have two managers fighting for leadership.operator/config/↔chart/must stay in sync:operator/config/(kustomize) andchart/(Helm) describe the same deployment surface through two tools. Any change to one must be mirrored into the other in the same PR — a new RBAC rule, env var, volume mount, resource limit, webhook wiring, or CRD underoperator/config/is only half-done until the equivalent edit lands underchart/templates/(andchart/values.yamlif it's a knob).make generate-helmcan draft the mirror by runninghelmifyover the kustomize output, but it overwrites hand-tuned chart files — read the diff carefully, revert the parts it gets wrong (templating variables, comments, ordering), and never commit the output blindly. For small changes, it's usually faster to hand-mirror than to re-generate.- CLI ↔ operator contract must stay in sync. The CLI (
operator/cmd/cli/) is a client of the operator — it reads/writes CRs, annotations (skyhook.nvidia.com/pause,…/disable,…/nodeState_*, etc.), status fields, and relies on well-known label/finalizer names. When an operator change touches any of these surfaces (renamed annotation, new CRD field the CLI should surface, changed status shape, removed feature, new lifecycle operation), update the CLI in the same PR. A PR that changes a contract surface inoperator/without a corresponding CLI update — or a justification for why the CLI is unaffected — should be rejected in review. - CLI must remain backward-compatible with older operators where at all possible. The CLI is distributed independently of the operator (users upgrade on different schedules) and is expected to work against any supported operator version. When adding a feature that depends on new operator behavior: feature-detect via
skyhook version/ CR schema presence / annotation probe, and fall back gracefully or emit a clear error naming the minimum operator version required. Do not silently no-op. The compatibility matrix indocs/cli.md(e.g., the v0.7.x vs v0.8.0 pause/disable table) is authoritative — every command with version-gated behavior must appear there. - When breaking CLI backward compatibility is unavoidable: (1) emit a runtime warning from the CLI when it detects an incompatible operator (naming the operator version it saw and the minimum required), (2) update the
docs/cli.mdcompatibility matrix and note the break in prose, (3) record the break inoperator/cmd/cli/CHANGELOG.mdwith a migration note, (4) bump the CLI major/minor perdocs/versioning.md. Silent breakage is not acceptable — operators in production may lag the CLI by months, and a CLI that misbehaves without explanation is worse than one that refuses to run. - Per-component version tags. Components are released independently — tags are prefixed with the component name:
operator/v0.15.0,cli/v0.3.0,agent/v6.4.0,chart/v0.15.0.git tag --list 'operator/*' --sort=-v:refname | head -1is how the operator Makefile computesGIT_TAG_LAST. A plaingit log --tagswithout this knowledge is confusing. Each component also has its ownCHANGELOG.md(root for chart+agent,operator/andoperator/cmd/cli/for the Go components). Seedocs/versioning.md/docs/release-process.md. make testinoperator/is heavy: it runs unit + four flavors of e2e and expects a running cluster. For quick iteration usemake unit-tests(ormake watch-tests).