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>
This commit is contained in:
stewartshea
2026-06-30 21:51:04 -04:00
commit eb8160e659
15 changed files with 1244 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
# 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 <name> -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).