Skip to content

feat(monetizeapi): controller-gen as canonical CRD schema source#525

Closed
bussyjd wants to merge 1 commit into
mainfrom
feat/controller-gen-codegen
Closed

feat(monetizeapi): controller-gen as canonical CRD schema source#525
bussyjd wants to merge 1 commit into
mainfrom
feat/controller-gen-codegen

Conversation

@bussyjd
Copy link
Copy Markdown
Collaborator

@bussyjd bussyjd commented May 23, 2026

Why

CRD-vs-Go drift is a documented recurring bug class. PurchaseAutoRefill.MaxTotal existed in purchaserequest-crd.yaml for months while types.go didn't read it — fixed by hand in PR #513 with no enforcement to prevent the next instance. This commit also adds back the missing MaxTotal and MaxSpendPerDay fields to the Go struct so the next reconcile actually reads what the CRD declares.

Before

   internal/monetizeapi/types.go   (Go struct, hand-edited)
                  ↕  drift (no enforcement)
   *-crd.yaml                      (OpenAPI schema, hand-edited)

   Result: fields appear/disappear independently. Caught at runtime
   when apiserver rejects a CR with "unknown field" or controller
   reads zero from a field that was supposed to be populated.

After

   internal/monetizeapi/types.go
   (kubebuilder markers are the source of truth)
                  │
                  ▼  just generate
                  │
   *-crd.yaml + zz_generated.deepcopy.go
   (machine-generated; CI fails if not in sync with markers)

   Result: one editable source. Schema, validations, printer columns,
   subresources, DeepCopy methods all derive from Go markers.
   `git diff` after `just generate` MUST be empty — CI enforces.

What changed

  • tools/tools.go — anchors controller-gen as a build dependency (v0.16.5, compatible with k8s.io/client-go v0.34.x)
  • justfilegenerate recipe runs controller-gen + renames obol.org_<plural>.yaml to existing <singular>-crd.yaml naming
  • hack/boilerplate.go.txt — generated-file marker (force-added past the repo's *.txt gitignore)
  • internal/monetizeapi/doc.go — package-level +groupName=obol.org / +versionName=v1alpha1 / +kubebuilder:object:generate=true
  • internal/monetizeapi/types.go — kubebuilder markers on every CRD-backed type, plus the previously-missing MaxTotal / MaxSpendPerDay fields on PurchaseAutoRefill
  • internal/monetizeapi/deepcopy_manual.go — hand-written DeepCopy for PreSignedAuth (its Payment map[string]interface{} is opaque to controller-gen)
  • internal/monetizeapi/zz_generated.deepcopy.go — generated DeepCopy methods for the rest of the package
  • internal/embed/infrastructure/base/templates/*-crd.yaml — regenerated (see diff section below)
  • .github/workflows/lint-test.yaml — new generate-check job: runs just generate and fails if git status --porcelain is non-empty

CRD diff after generation

Bit-exact round-trip wasn't possible because controller-gen normalises layout. The unavoidable differences between hand-written and generated CRDs are:

  1. Top-of-file comments dropped. controller-gen does not preserve the leading narrative comments (e.g. # ServiceOffer CRD\n# Defines a compute service...). All structural descriptions are preserved as description: fields on the matching property; the narrative comments are reproduced as Go doc comments on the corresponding types in types.go.
  2. controller-gen.kubebuilder.io/version: v0.16.5 annotation added on every generated CRD — required for CI to detect drift via diff and lets future operators see which controller-gen version produced the YAML.
  3. YAML key ordering and indentation changed. controller-gen alphabetises keys within objects (e.g. additionalPrinterColumns before name, singular after shortNames) and uses 2-space indentation throughout. The hand-written files used 4-space and a CRD-conventional ordering. The OpenAPI schemas are semantically identical; kubectl apply is order-insensitive.
  4. apiVersion + kind + metadata properties appear in every CR's openAPIV3Schema. controller-gen always emits these to match what apiserver expects; they were elided in the hand-written files. No behaviour change.
  5. PurchaseAutoRefill gained maxTotal (integer) and maxSpendPerDay (string) on the Go side. Both fields already existed in the prior hand-written CRD but were silently missing from types.go — this is the bug class the PR exists to close.
  6. Top-of-file --- separator on the (previously-missing) purchaserequest-crd.yaml added by controller-gen; harmless.

No fields were dropped. No validations were loosened. The ^0x[0-9a-fA-F]{40}$ pattern on payTo, the eip3009;permit2 enum on transferMethod, the ^/[a-zA-Z0-9/_.-]*$ path pattern, the 1-65535 port range, the 1-2500 count range, the inference;fine-tuning;http;agent type enum, all printer columns, and all subresources.status declarations are preserved.

Test plan

  • just generate (executed as the inlined shell script since just isn't installed locally) produces zero diff on a clean re-run
  • go build ./... clean
  • go test ./internal/embed/... green — the embed CRD parse + schema tests still pass against the regenerated YAML
  • go test ./internal/monetizeapi/... ./internal/serviceoffercontroller/... ./internal/x402/... ./internal/x402/buyer/... green
  • go vet ./... clean (only pre-existing internal/enclave/enclave_darwin.go unsafe-pointer warnings remain)
  • internal/stack TestWarnIfNoChatModel_EmitsWarnWhenNoModels failure verified as pre-existing on origin/main (unrelated)

Future

Unblocks any future CRD changes (e.g. spec.paused + metav1.Condition) — those become "edit Go markers, run just generate, commit" instead of hand-editing CRDs twice and drifting.

Closes the entire class of "CRD YAML and Go struct drifted" bugs.
PurchaseAutoRefill.MaxTotal was the most recent instance — it existed
in purchaserequest-crd.yaml for months while internal/monetizeapi/
types.go didn't have the corresponding field. Without this commit,
that pattern recurs by design: two sources of truth, one hand-
maintained, no enforcement of agreement.

Now Go is the single source of truth:
  - kubebuilder markers on every CRD-backed struct in types.go
    (validation, required, enum, pattern, printer columns, subresources)
  - `just generate` regenerates *-crd.yaml from those markers
    + zz_generated.deepcopy.go from object:generate=true
  - CI fails if `git status` is non-empty after `just generate` runs

This commit also fixes the documented MaxTotal / MaxSpendPerDay drift
by adding both fields to PurchaseAutoRefill — the generated CRD now
matches the prior hand-written one and the controller can read them.

Pinned controller-tools at v0.16.5 in tools/tools.go (compatible with
client-go v0.34.x; a newer release would force prometheus/common
through a panicking validation-scheme change). Generation is
deterministic; running locally produces no diff after a clean
checkout.

For future CRD edits:
  1. Edit types.go (add/change a field, update markers)
  2. `just generate`
  3. Commit both the Go and YAML diffs
  4. CI verifies the YAML was committed

PreSignedAuth.Payment is map[string]interface{} (opaque x402
payload), which controller-gen cannot deep-copy automatically; a
hand-written DeepCopy lives in deepcopy_manual.go and the type is
flagged object:generate=false.

The hack/boilerplate.go.txt file is force-added past *.txt gitignore;
it's an empty marker for now — add a copyright header later if the
repo settles on one.
Comment on lines +48 to +73
name: CRD generation up-to-date
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Set up Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version-file: 'go.mod'

- name: Set up just
uses: extractions/setup-just@dd310ad5a97d8e7b41793f8ef055398d51ad4de6 # v2.0.2

- name: Regenerate CRDs + DeepCopy
run: just generate

- name: Fail if regeneration changed any tracked files
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "::error::CRD manifests or DeepCopy methods are out of date."
echo "::error::Run 'just generate' locally and commit the result."
git status
git --no-pager diff
exit 1
fi
@bussyjd
Copy link
Copy Markdown
Collaborator Author

bussyjd commented May 24, 2026

Superseded by bundle PR #536 — closing in favor of the consolidated merge target. Original branch and history preserved.

@bussyjd bussyjd closed this May 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants