# 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 ```bash 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 ```bash 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: ```bash kubectl get buckets.storage.gcp.upbound.io -o json | jq .status.conditions ``` Expected shape: ```json [ { "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`](https://github.com/runwhen-contrib/runwhen-local/blob/main/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: ```yaml 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`](https://github.com/runwhen/infra-flux-nonprod-shared/blob/main/apps/runwhen-env/airgap/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](04-generation-rule-walkthrough.md).