Purpose — every command you need to build, lint, test and regenerate the operator, plus how the vendored subcharts and patched dependencies work. Audience contributors · Prerequisites none (read Architecture before changing behavior) · Read time ~6 min
- Commands
- Checks whose failure reads as success
- Shipped specification corrections
- Runtime log verbosity
- API groups & code generation
- Vendored / patched dependencies
make <target> [args] runs hack/<target>.sh and forwards args to it. All builds use CGO_ENABLED=1,
GODEBUG=gotypesalias=0 and the build tags goccy netgo.
make deps— vendor patched k8s staging modules intostaging/and upstream Helm charts intodeploy/gpustack-operator/chart/charts/, thengo mod tidy && go mod download;make deps updateaddsgo get -u ./....make generate— thegen/apigenerators: deepcopy, register, apiservice, CRDs, conversion, protobuf, webhooks.make generate bindingregenerates the CGO bindings inbinding/via c-for-go.make lint— golangci-lint (.golangci.yaml);make lint dirtyalso fails on a dirty tree.make lint docschecks the documentation contract instead — bash and awk over the corpus, a second or two, and no cluster; see the docs skill.make build— cross-buildcmd/gpustack-operatorinto.dist/build/, version ldflag-injected intopkg/utils/version;VERSION=vX.y.z+l.m make buildsets it,BUILD_PLATFORMS="linux/amd64 linux/arm64"cross-compiles.make test—go test -v -failfast -race -cover -shuffle=on -timeout=30m ./..., coverage to.dist/test/coverage.out. The order is shuffled on every run so an order-dependent test cannot hide behind the fixed one; a failure banner prints the seed, andgo test -shuffle=<seed>reproduces that exact order. Trailing args are regexes of packages to exclude.RACE=false make testdrops-raceand changes nothing else.make package— images viadocker buildxfrompack/*/Dockerfile(Linux only).
CI (hack/ci.sh) runs make generate && make deps && make lint && make build inside the image build. The unit tests run
separately, in test.yml, as RACE=false make test on linux/amd64 and linux/arm64.
An image built from a git worktree carries the worktree's .git pointer file but not the gitdir it names, so git cannot read
the tree inside the build. There the .agents shell gate, hack/check-agents-shell.sh, reports itself skipped with its reason
instead of failing the build: in local mode it checks only uncommitted changes, so inside an image its set is empty even from a
clone. The verdict on committed .agents shell comes from agents-shell.yml and from make lint on the host.
make lintwrites. It runsgoimports-reviser -output=fileandgolangci-lint --fix, so it edits the source rather than only reading it. Anything generated before it —make generate,make generate chart— was produced from a version of the source that no longer exists, and nothing downstream notices: the build passes, the tests pass, andgit statusis clean. Regenerate after linting.api.ymlandchart.ymlboth fail when the committed artifacts do not match a fresh regeneration, which is what catches it when nobody remembers.
The image build lints with its own golangci-lint, and it can be stricter than the one on your host: the packaged build runs
make lintinside the image, where the pinned.golangci.yamlapplies against the image's golangci-lint release, and a rule your host binary does not enable —predeclared, which refuses parameter names likenew— fails there while the tree passed locally minutes earlier. When the packaged build fails a rule your host never reported, fix the name rather than the config: the image's verdict is the one CI ships.
generate, lint and test take a chart argument, operating on deploy/gpustack-operator/chart via
chart-testing,
helm-docs and helm-schema:
make generate chart— regenerateREADME.md(fromREADME.md.gotmpl) andvalues.schema.json. Never hand-edit those two: editvalues.yaml/its annotations/README.md.gotmpland re-run, after anyvalues.yamledit — the generated schema is what rejects a bad install. Nothing invalues.yamlis generated: Kueue'sresources.transformationsis rendered at install time by a chart helper a patch adds to its config.make lint chart—ct lintin a container, then assert theglobal.*image knobs reach every image the chart and its subcharts render (gpustack::helm::verify_images).make test chart—ct installonto the current cluster in a container; needs a reachable cluster (e.g. kind) and~/.kube/config. It installsCHART_TEST_IMAGE_REPOSITORY:CHART_TEST_IMAGE_TAG, by default the publishedgpustack/gpustack-operator:dev, which is built frommain. To test a chart change together with the binary it needs, build the tree, load the image into the cluster, and name it in those two variables. The Chart workflow does this on every run.
Kueue, Node Feature Discovery, csi-driver-nfs and csi-driver-s3 are vendored unpacked under
deploy/gpustack-operator/chart/charts/<name>/ and committed, so helm install works from a bare
clone and CI stays offline.
gpustack::chart_staging (hack/deps.sh) pulls each pinned archive, unpacks it, stamps _VERSION_ and
applies hack/deploy/gpustack-operator/chart/charts/<name>/*.patch; a tree at the pinned stamp is skipped,
so runs are idempotent and a patched tree is never clobbered.
Unpacked is what lets the patches exist: Helm merges subchart values rather than rendering them, so the
parent cannot compose global.imageRegistry into a subchart's image.repository; each tree's
global-image.patch makes the subchart's templates read .Values.global.*, which Helm does propagate.
To change an upstream chart:
- Never edit a staged tree in place — a version bump makes
make depsdelete and re-unpack it, so every change lives in a patch file. - Write the patch against the unpacked tree (
git difffrom a scratch copy works), drop it intohack/deploy/gpustack-operator/chart/charts/<name>/, bump the pinned version inhack/deps.shif that is the change, and re-runmake deps. - A patch that no longer applies, or leaves a
.rej, failsmake deps— otherwise a moved chart ships half-patched and silent. A shifted hunk is fine:patchruns-F0, so context still matches exactly, and two patches on one file shift each other.
Mirror the images before bumping a pinned version: every image the chart renders points at a
gpustack/mirrored-* repository, an unmirrored bump lands every install in ImagePullBackOff, and
make lint chart only checks that the override knobs reach each reference, not that it resolves.
chart.yml runs all three across the supported Kubernetes matrix and gates drift: make generate chart
must leave README.md/values.schema.json unchanged, make deps the vendored trees; both fail with the
command to run. For a full install → version-consistency → uninstall cycle on a real cluster use the
gpustack-operator-chart-e2e skill, and gpustack-operator-e2e for scheduling-chain behavior.
Conventional Commits (type: subject), checked by
commitsar (v1.0.3, pinned in hack/lib/style.sh) within
make lint, but only over a clean tree (hack/lint.sh → gpustack::commit::lint) and only across the
commits ahead of origin/main (scope in .commitsar.yml). Types: feat, fix, refactor, test,
docs, chore.
code-review.yml runs an AI review on every pull request (opened/synchronize/reopened) and posts
inline findings plus a summary under the org's GitHub App identity. The pipeline itself lives in
gpustack/.github
as a reusable workflow — this repository only pins its model backing and maps the org secrets. To
re-review the latest head, comment /open-code-review on the PR (MEMBER/OWNER/COLLABORATOR only).
What each file is reviewed against is owned here, in .opencodereview/rule.json — its
include/exclude lists and path-scoped rules are the single source of truth; a review-scope change
lands there, not in this document. Verify a rule change before committing: npx -y @alibaba-group/open-code-review rules check <path> shows which rule a file resolves to; npx -y @alibaba-group/open-code-review review --preview shows which files a diff would send to review.
make test only excludes packages, so target one package or test with go test directly:
GODEBUG=gotypesalias=0 CGO_ENABLED=1 go test -race ./pkg/nodefeature/...
GODEBUG=gotypesalias=0 CGO_ENABLED=1 go test -race -run TestExtractGeneralNodeKey ./pkg/nodefeature/REQUIRED: take a check's verdict from its return code and from the object under test, never from
the shape of its output. Each trap below broke with the signal taken as the verdict reading as
success, while the failure sat in a channel nobody was reading: stderr, a return code, or a line
that was never printed. The shell traps are about the prompt these commands get typed at, which on
macOS is zsh; the repository's own scripts run under bash with pipefail set.
An unquoted $var does not word-split in zsh. FILES="a b c"; cp $FILES $dir passes the list
as one filename and the copy fails, where bash would split it into three arguments. Keep a list in
an array and expand it as "${FILES[@]}": a bare $FILES over an array is three words in zsh but
only its first element in bash. The comparison downstream then read a match, because both sides were
the empty string a failed git hash-object returned.
zsh arrays are 1-indexed. A for i in 0 1 2 3 4 loop over ${IDS[$i]} drops one end. Two
arrays stepped together stay aligned, so the other iterations land correctly and the single missing
one reads as a flake rather than as a boundary error.
A pipeline reports only its last command. make lint | tail -5; echo "rc=$?" gives tail's
status, not lint's, unless pipefail is set -- and neither zsh nor bash sets it by default. A green
lint prints nothing after its closing banner, so "the code is 0" and "the last line is the banner"
confirm each other. Redirect instead: make lint >/tmp/out.log 2>&1; echo "RC=$?".
An empty result is not a negative result. A grep -c of 0 is a real count from whatever search
ran, and a dropped -i quietly changes which search that is. A command that never ran prints no
count at all: a missing path, or -P on the macOS grep, exits 2 with its message on stderr, where
|| echo none prints the reassuring branch over it. Feed the check an input it MUST match first.
Let the check veto the cleanup. Verification and teardown joined by ; tear down even when the
verification failed, costing the evidence needed to diagnose it; && is the guard, and it guards
only a check that exits non-zero. Carry the verdict in the status --
[ -n "$a" ] && [ -n "$b" ] && [ "$a" = "$b" ] && rm -rf "$tree" -- rather than printing SAME or
DIFF and returning 0 either way. The non-empty tests matter: two missing values compare equal.
Shipped specifications are historical design records. A later design change MUST be recorded in a new specification whose header names the earlier sections it supersedes; leave those sections unchanged.
An in-place edit is ALLOWED only when evidence proves a factual claim or its supporting reason wrong
while the shipped design and conclusion remain unchanged. Mark prose **Corrected after shipping.**, or
use Corrected after shipping. inside a preserved code block. Retain enough of the former claim to
explain the correction, and state the replacement evidence at the same location. Keep the edit to that
correction; terminology, formatting and later design belong elsewhere.
A bug fix writes no NEW specification into this repository. Three shipped ones predate that rule and stay exactly where they are, as the historical records they already were. What it changes is the FIRST paragraph above: a design change a bug fix makes has no new specification to be recorded in, so recording it there is impossible rather than merely skipped.
Record it in the superseded section instead, marked **Corrected after shipping.**. That is the SECOND
paragraph's marker carrying one thing that paragraph otherwise forbids — the resulting rule does change
here — so the note MUST name the page that carries the rule now. Everything else in that paragraph
still applies: retain enough of the former text to explain what changed, and keep the edit to it.
A superseded section MUST NOT be left carrying a file name that resolves to nothing — the reader
follows it and lands nowhere. Naming the document is fine and often necessary; what must go is the
extension that makes the name a path. So write 2026-01-02-a-thing rather than 2026-01-02-a-thing.md
once that file is gone. The prohibition is on the dangling path, not on the mention.
Every component registers PUT /debug/flags/v on its own secure port, so klog verbosity can be raised on
a running pod and dropped again without a restart (pkg/manager/manager.go, pkg/worker/worker.go,
pkg/workergateway/gateway.go; the device-manager's port is 32443, from pkg/devicemanager/option.go).
kubectl -n gpustack-system exec <pod> -- \
curl -sk -X PUT -H "Host: 127.0.0.1" -d '4' https://127.0.0.1:32443/debug/flags/v
# → successfully set klog.logging.verbosity to 4
kubectl -n gpustack-system exec <pod> -- \
curl -sk -X PUT -H "Host: 127.0.0.1" -d '2' https://127.0.0.1:32443/debug/flags/v-H "Host: 127.0.0.1" is mandatory: httpx.LoopbackAccessHandlerFunc compares r.Host against the
bare 127.0.0.1 / localhost / ::1 and an ordinary request carries the port (127.0.0.1:32443), so
without it the guard answers a plain 404 that reads like a missing route. A GET with the header
answers 406 unsupported http method — the guard passing.
Use it to see a decision logged above the deployment's verbosity. The device plugin is the sharpest case:
its ResourceServers use Logger: logger.V(3) (pkg/devicemanager/allocator/allocator.go) while the
DaemonSet runs -v=2, so Allocate/GetPreferredAllocation decisions — which accelerator a slice landed
on — are discarded by default.
Raise v before creating the workload to trace; those lines fire only on an allocation, so a quiet
window afterwards proves nothing. The gpustack-operator-e2e skill carries the same recipe as a triage
step, with the operational caveats.
| Path | Group / Version | Kind |
|---|---|---|
api/v1 |
gpustack.ai/v1 |
Extension API (settings, status) |
api/worker/v1 |
worker.gpustack.ai/v1 |
Extension API served by the aggregated apiserver: a proxy/conversion over the v1alpha1 CRDs plus the read-only (get, list, watch) InstanceTypeFlavor catalog |
api/worker/v1alpha1 |
worker.gpustack.ai/v1alpha1 |
CRDs (Instance, Devices, InstanceType) |
gen/api/main.go configures which packages are CRDs vs extension APIs and drives the custom generators in
gen/api/generator (apireg-gen, crd-gen, webhook-gen). Never hand-edit generated files
(zz_generated.*, generated.pb.go, generated.proto): edit the source *.go types or gen/api/main.go
and run make generate (the gpustack-operator-generate skill automates this).
The patched applyconfiguration generator strips list and continuation indentation from API comments
in staging/k8s.io/code-generator/cmd/applyconfiguration-gen/generators/applyconfiguration.go:317-326
(commentsWithoutMarkers). A comment scan of pkg/kubeclients/applyconfiguration/ can therefore report
list formatting that cannot be fixed by editing the source comment; fix the generator patch instead.
go.mod replaces several k8s modules (k8s.io/api, apimachinery, code-generator,
apiextensions-apiserver, kube-aggregator, klog) plus gogo/protobuf and go-logr/logr with patched
copies under ./staging/, checked out and patched by make deps (sources + versions in hack/deps.sh,
patches in hack/staging/). Don't hand-edit staging/; change the patch and re-run make deps. The
subcharts are staged the same way, for the same reason — see Vendored subcharts.
See also — Internals (the invariants the code keeps) · Installation Modes · Settings
Next → All documentation — pick the next page on the contributor path.