Files
simple-private-codecollection/docs/03-crossplane-crd-discovery.md
T
stewartshea eb8160e659 Initial scaffold: generation-rule-only Crossplane Bucket example
Educational RunWhen CodeCollection that discovers Crossplane GCP
Bucket CRDs (storage.gcp.upbound.io/v1beta1) and generates one SLX
per bucket. Ships only generation rules and Jinja templates; the
runtime lives in rw-generic-codecollection/k8s-kubectl-cmd (already
loaded by the airgap runner).

Includes:
- codebundles/gcp-bucket-crossplane-health with generation rule + 3 templates
- docs/01..07 numbered training chapters with screenshot placeholders
- README, .gitignore

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 21:51:04 -04:00

5.1 KiB

03 — Crossplane CRD discovery

What you'll do

Understand how RunWhen's Kubernetes indexer discovers custom resources — specifically the Crossplane GCP Bucket CRD — driven entirely by what your generation rules ask for.

Step 1 — Confirm the CRD exists

kubectl api-resources | grep storage.gcp.upbound.io

Expected output (trimmed):

buckets                                          storage.gcp.upbound.io/v1beta1               false        Bucket
bucketiammembers                                 storage.gcp.upbound.io/v1beta1               false        BucketIAMMember
hmackeys                                         storage.gcp.upbound.io/v1beta1               false        HMACKey

Three things to note:

  1. buckets — this is the plural resource name. It is what you put in the generation rule, not the singular Bucket.
  2. storage.gcp.upbound.io/v1beta1 — the API group and preferred version.
  3. false in the "NAMESPACED" column — Crossplane managed resources are cluster-scoped. This is why our generation rule uses qualifiers: ["resource", "cluster"] and not namespace.

Screenshot placeholder: images/03-kubectl-api-resources.png — output of the above command.

Step 2 — Confirm buckets exist to discover

kubectl get buckets.storage.gcp.upbound.io -o wide

Sample output on the airgap cluster:

NAME                                                  READY   SYNCED   EXTERNAL-NAME
runwhen-nonprod-shared-litellm-logging                True    True     runwhen-nonprod-shared-litellm-logging
runwhen-nonprod-shared-loki                           True    True     runwhen-nonprod-shared-loki
runwhen-nonprod-shared-mimir                          True    True     runwhen-nonprod-shared-mimir
runwhen-nonprod-shared-tempo                          True    True     runwhen-nonprod-shared-tempo

Screenshot placeholder: images/03-kubectl-buckets.png.

Step 3 — Look at the .status.conditions[] we care about

This is what the SLI and TaskSet will read. Pick any bucket and inspect it:

kubectl get buckets.storage.gcp.upbound.io <name> -o json | jq .status.conditions

Expected shape:

[
  {
    "lastTransitionTime": "2025-11-14T10:15:11Z",
    "reason": "Available",
    "status": "True",
    "type": "Ready"
  },
  {
    "lastTransitionTime": "2025-11-14T10:15:10Z",
    "reason": "ReconcileSuccess",
    "status": "True",
    "type": "Synced"
  }
]

Every Crossplane managed resource carries these two conditions:

  • Ready — the external resource (the actual GCS bucket in GCP) exists and is available. When Ready=False, something in GCP is wrong (missing IAM, quota, deleted out-of-band, etc.).
  • Synced — the last reconcile between the Crossplane spec and the provider succeeded. When Synced=False, Crossplane could not talk to the provider or hit a validation error.

Our SLI computes (#True conditions) / (#total conditions), so a healthy bucket returns 1.0 and any single failing condition drops it to 0.5 or 0.0.

Step 4 — Understand how workspace-builder gets there

RunWhen Local's Kubernetes indexer performs selective discovery: it only lists custom resource types that appear in loaded generation rules. That's why simply declaring buckets.storage.gcp.upbound.io/v1beta1 in our rule is enough — no extra customResourceTypes: list in workspaceInfo.yaml.

The parsing (in runwhen-local/src/indexers/kubetypes.py) takes the string plural.group/version and splits it into (plural='buckets', group='storage.gcp.upbound.io', version='v1beta1'). It then calls CustomObjectsApi.list_cluster_custom_object(...).

You can also specify custom resources with a dict form, useful when you need to be explicit about a particular version:

resourceTypes:
  - platform: kubernetes
    resourceType: custom
    kind: buckets              # plural, NOT PascalCase
    group: storage.gcp.upbound.io
    version: v1beta1

Common pitfalls

  • Wrong plural — the biggest source of "nothing gets discovered". Always copy the plural from kubectl api-resources, never guess it from the Kind.
  • Version omission — leaving off /v1beta1 uses the API server's preferred version. Usually fine but pin it explicitly if you rely on a specific schema.
  • RBAC — workspace-builder needs get/list/watch on the CRD. The airgap runner already binds workspace-builder SA to view at the cluster scope via rbac/workspace-builder-view-rbac.yaml, which covers CRDs installed after the binding was created.
  • Namespace scope — some CRDs are namespaced. If you had chosen a namespaced CRD you would need to include the CRD's namespaces in your cloudConfig.kubernetes.namespaces list or leave that list empty.

Next

Continue to 04 — Generation rule walkthrough.