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:
@@ -0,0 +1,75 @@
|
||||
# 01 — Overview
|
||||
|
||||
## What you'll do in this guide
|
||||
|
||||
By the end of this guide you will have a private RunWhen CodeCollection that:
|
||||
|
||||
1. Is hosted in your Gitea instance (already the case for this workspace —
|
||||
see `https://gitea.airgap.shared.runwhen.com/gitea-admin/simple-private-codecollection`).
|
||||
2. **Contains no Robot Framework code of its own.** It only ships two things:
|
||||
- **Generation rules** — YAML that tells RunWhen's workspace-builder
|
||||
which resources to discover in the cluster and how many SLXs to
|
||||
generate for each match.
|
||||
- **Templates** — Jinja files that render the `ServiceLevelX`,
|
||||
`ServiceLevelIndicator`, and `Runbook` resources for each match.
|
||||
3. Reuses an existing generic codebundle — `k8s-kubectl-cmd` from
|
||||
[`rw-generic-codecollection`](https://github.com/runwhen-contrib/rw-generic-codecollection/tree/main/codebundles/k8s-kubectl-cmd) —
|
||||
as the runtime for every generated SLI and TaskSet.
|
||||
4. Discovers **Crossplane GCP Bucket** custom resources
|
||||
(`buckets.storage.gcp.upbound.io/v1beta1`) and produces one SLX per
|
||||
Bucket with a fractional health SLI and a detailed condition-based
|
||||
TaskSet.
|
||||
|
||||
## Vocabulary primer
|
||||
|
||||
| Term | Meaning |
|
||||
|------|---------|
|
||||
| **CodeCollection** | A Git repository, cloned by RunWhen, that contains one or more codebundles. This repo is a CodeCollection. |
|
||||
| **CodeBundle** | A subdirectory of a CodeCollection (`codebundles/<name>/`). Traditionally contains Robot Framework code plus discovery rules. **Ours contains only discovery rules and templates.** |
|
||||
| **Generation rule** | YAML under `.runwhen/generation-rules/`. Declares which resource types to scan and how to build SLXs from matches. |
|
||||
| **Template** | Jinja YAML under `.runwhen/templates/`. Rendered per matching resource by workspace-builder to produce the final SLX / SLI / Runbook artifacts. |
|
||||
| **SLX** | A "Service Level X" — the top-level unit of monitoring in RunWhen. Bundles together an SLI, an optional SLO, and one or more Runbooks. |
|
||||
| **SLI** | Service Level Indicator — a numeric health signal (here `0.0` to `1.0`) pushed on an interval. |
|
||||
| **Runbook / TaskSet** | The interactive troubleshooting sequence. Called a "Runbook" in `kind`, "TaskSet" in the UI. |
|
||||
| **workspace-builder** | The pod (image `runwhen-local`) that clones code collections, indexes resources, matches generation rules, renders templates, and uploads the resulting workspace to the platform. |
|
||||
|
||||
## The pattern in one diagram
|
||||
|
||||

|
||||
|
||||
*(Placeholder — replace with a screenshot exported from your diagramming
|
||||
tool once the flow is verified end-to-end.)*
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Private["Gitea<br/>simple-private-codecollection<br/>(rules + templates)"]
|
||||
WB["workspace-builder<br/>(clones + indexes + renders)"]
|
||||
Cluster[("GKE cluster<br/>Crossplane Bucket CRDs")]
|
||||
Generic["rw-generic-codecollection<br/>k8s-kubectl-cmd<br/>(sli.robot + runbook.robot)"]
|
||||
|
||||
Private --> WB
|
||||
Cluster --> WB
|
||||
WB -->|"one SLX per Bucket"| Platform["RunWhen Platform"]
|
||||
Platform -->|"Runner pulls robots from"| Generic
|
||||
Generic -->|kubectl + jq| Cluster
|
||||
```
|
||||
|
||||
## When to use this pattern
|
||||
|
||||
Choose the "generation-rule only" pattern when:
|
||||
|
||||
- You want to **discover organization-specific resources** (CRDs, tags,
|
||||
labels) without maintaining a fork of the shared code collections.
|
||||
- The **runtime already exists** as a generic bundle
|
||||
(`k8s-kubectl-cmd`, `curl-cmd`, `gcloud-cmd`, `aws-cmd`, etc.) and you
|
||||
just need to point it at the right target.
|
||||
- You want changes to your discovery rules to land **without a codebundle
|
||||
runner image rebuild** — workspace-builder re-clones on each reconcile.
|
||||
|
||||
Do NOT use it when you need custom Robot keywords, a task that
|
||||
can't be expressed as a single `kubectl`/`gcloud`/`curl` command, or a
|
||||
runtime that isn't already available in a generic bundle.
|
||||
|
||||
## Next
|
||||
|
||||
Continue to [02 — Generation-rule-only pattern](02-generation-rules-only-pattern.md).
|
||||
@@ -0,0 +1,124 @@
|
||||
# 02 — The generation-rule-only pattern
|
||||
|
||||
## What you'll do
|
||||
|
||||
Understand the split of responsibilities between this private code
|
||||
collection (rules + templates only) and the shared `rw-generic-codecollection`
|
||||
(the actual Robot runtime).
|
||||
|
||||
## Traditional codebundle vs. rules-only codebundle
|
||||
|
||||
A **traditional** codebundle looks like this:
|
||||
|
||||
```
|
||||
codebundles/k8s-my-thing/
|
||||
meta.yaml
|
||||
README.md
|
||||
runbook.robot # ← runtime
|
||||
sli.robot # ← runtime
|
||||
.runwhen/
|
||||
generation-rules/*.yaml
|
||||
templates/*.yaml # templates reference this bundle's own robots
|
||||
```
|
||||
|
||||
A **rules-only** codebundle looks like this — this is the pattern we are
|
||||
teaching:
|
||||
|
||||
```
|
||||
codebundles/gcp-bucket-crossplane-health/
|
||||
README.md
|
||||
.runwhen/
|
||||
generation-rules/gcp-bucket-crossplane-health.yaml
|
||||
templates/
|
||||
gcp-bucket-crossplane-health-slx.yaml
|
||||
gcp-bucket-crossplane-health-sli.yaml
|
||||
gcp-bucket-crossplane-health-taskset.yaml
|
||||
```
|
||||
|
||||
Notice: no `meta.yaml`, no `runbook.robot`, no `sli.robot`. The templates
|
||||
still reference `pathToRobot`, but they point at a **different**
|
||||
code collection.
|
||||
|
||||
## The delegation trick
|
||||
|
||||
Look at [`gcp-bucket-crossplane-health-sli.yaml`](../codebundles/gcp-bucket-crossplane-health/.runwhen/templates/gcp-bucket-crossplane-health-sli.yaml):
|
||||
|
||||
```yaml
|
||||
codeBundle:
|
||||
repoUrl: http://rw-airgap-cc-catalog-svc.runwhen-env-airgap:8080/git/rw-generic-codecollection.git
|
||||
ref: main
|
||||
pathToRobot: codebundles/k8s-kubectl-cmd/sli.robot
|
||||
configProvided:
|
||||
- name: KUBECTL_COMMAND
|
||||
value: |
|
||||
kubectl get buckets.storage.gcp.upbound.io {{match_resource.resource.metadata.name}} ...
|
||||
```
|
||||
|
||||
`repoUrl` points at the airgap cc-catalog proxy for
|
||||
`rw-generic-codecollection`. `pathToRobot` picks the generic
|
||||
`k8s-kubectl-cmd/sli.robot`. `configProvided.KUBECTL_COMMAND` is the actual
|
||||
command we want that generic robot to run — parameterized by the discovered
|
||||
resource's name via the `match_resource` Jinja variable.
|
||||
|
||||
At runtime, the workflow is:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant WB as workspace-builder
|
||||
participant CR as Cluster (kubeapi)
|
||||
participant Repo as private CC (Gitea)
|
||||
participant PAPI as RunWhen Platform
|
||||
participant Worker as Runner Worker
|
||||
participant Generic as rw-generic-codecollection
|
||||
|
||||
WB->>Repo: git clone (with token)
|
||||
WB->>CR: list buckets.storage.gcp.upbound.io
|
||||
CR-->>WB: [ Bucket A, Bucket B, ... ]
|
||||
WB->>WB: match rule; render templates
|
||||
WB->>PAPI: upload N SLXs (each with codeBundle.repoUrl = rw-generic)
|
||||
PAPI->>Worker: schedule SLI + TaskSet runs
|
||||
Worker->>Generic: clone at ref=main; load k8s-kubectl-cmd
|
||||
Worker->>CR: KUBECTL_COMMAND (kubectl + jq)
|
||||
CR-->>Worker: JSON status
|
||||
Worker->>PAPI: push metric / issues
|
||||
```
|
||||
|
||||
## Why not just put the robot files in the private CC?
|
||||
|
||||
Because then every consumer of the private CC would need to run the
|
||||
runner-side clone and image handling for those robots. The airgap runner's
|
||||
`runner.codeCollections` list would have to include this repo (with the
|
||||
same token concerns). By keeping the private CC as **rules and templates
|
||||
only**, you never have to update the runner-side collections list — the
|
||||
Robot code always resolves to the same shared, cached, image-baked
|
||||
generic collection that the runner already knows how to load.
|
||||
|
||||
## The template variables you have
|
||||
|
||||
At render time, workspace-builder passes each template a rich context.
|
||||
The most useful entries for our use case:
|
||||
|
||||
| Variable | Value in our case |
|
||||
|----------|-------------------|
|
||||
| `slx_name` | Generated SLX name, e.g. `shared-cluster-runwhen-nonprod-shared-litellm-logging-xp-bkt-hlth` |
|
||||
| `match_resource.resource` | The full Kubernetes object as returned by the API |
|
||||
| `match_resource.resource.metadata.name` | The Bucket's name |
|
||||
| `match_resource.kind` | `Bucket` (for CRDs, filled by the indexer) |
|
||||
| `match_resource.qualified_name` | The composite ID used by the resource registry |
|
||||
| `qualifiers` | The dict populated per your `qualifiers` list in the rule (`resource`, `cluster`) |
|
||||
| `cluster.name` | The Kubernetes cluster name (e.g. `shared-cluster`) |
|
||||
| `default_location` | Workspace default runner location |
|
||||
| `workspace.owner_email` | From `workspaceInfo.workspaceOwnerEmail` |
|
||||
| `custom.kubeconfig_secret_name` | From the `custom:` block in workspaceInfo |
|
||||
| `wb_version` | Set only in newer workspace-builders (used to gate the newer `kubernetes-auth.yaml` include) |
|
||||
|
||||
## Screenshot placeholders
|
||||
|
||||
- `images/02-side-by-side-file-tree.png` — screenshot of the two
|
||||
directory trees (traditional vs. rules-only) side by side.
|
||||
- `images/02-render-sequence.png` — annotated version of the sequence
|
||||
diagram above (optional).
|
||||
|
||||
## Next
|
||||
|
||||
Continue to [03 — Crossplane CRD discovery](03-crossplane-crd-discovery.md).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,165 @@
|
||||
# 04 — Generation rule walkthrough
|
||||
|
||||
## What you'll do
|
||||
|
||||
Read the generation rule at [`codebundles/gcp-bucket-crossplane-health/.runwhen/generation-rules/gcp-bucket-crossplane-health.yaml`](../codebundles/gcp-bucket-crossplane-health/.runwhen/generation-rules/gcp-bucket-crossplane-health.yaml)
|
||||
line by line and understand each block.
|
||||
|
||||
## The full file
|
||||
|
||||
```yaml
|
||||
apiVersion: runwhen.com/v1
|
||||
kind: GenerationRules
|
||||
spec:
|
||||
platform: kubernetes
|
||||
generationRules:
|
||||
- resourceTypes:
|
||||
- buckets.storage.gcp.upbound.io/v1beta1
|
||||
matchRules:
|
||||
- type: pattern
|
||||
pattern: ".+"
|
||||
properties: [name]
|
||||
mode: substring
|
||||
slxs:
|
||||
- baseName: xp-bkt-hlth
|
||||
qualifiers: ["resource", "cluster"]
|
||||
baseTemplateName: gcp-bucket-crossplane-health
|
||||
levelOfDetail: detailed
|
||||
outputItems:
|
||||
- type: slx
|
||||
- type: sli
|
||||
- type: runbook
|
||||
templateName: gcp-bucket-crossplane-health-taskset.yaml
|
||||
```
|
||||
|
||||
## Block-by-block
|
||||
|
||||
### Envelope
|
||||
|
||||
```yaml
|
||||
apiVersion: runwhen.com/v1
|
||||
kind: GenerationRules # note the plural — the loader rejects "GenerationRule"
|
||||
spec:
|
||||
platform: kubernetes # per-file default; can be overridden per rule
|
||||
generationRules: [ ... ] # one or more rules
|
||||
```
|
||||
|
||||
- `kind` must be exactly `GenerationRules`. RunWhen Local's loader
|
||||
compares this to a schema constant.
|
||||
- `spec.platform` sets the default platform for every rule in the file.
|
||||
- `spec.generationRules` is a list — you can define many rules per file.
|
||||
|
||||
### `resourceTypes`
|
||||
|
||||
```yaml
|
||||
resourceTypes:
|
||||
- buckets.storage.gcp.upbound.io/v1beta1
|
||||
```
|
||||
|
||||
This is the *selective discovery* signal. Because this string appears in
|
||||
a loaded rule, the Kubernetes indexer knows to enumerate that CRD.
|
||||
Without any rule mentioning a CRD, it would not be listed.
|
||||
|
||||
You can list multiple types in one rule if you want the same SLX shape
|
||||
for each. Splitting into separate rules is usually cleaner though.
|
||||
|
||||
### `matchRules`
|
||||
|
||||
```yaml
|
||||
matchRules:
|
||||
- type: pattern
|
||||
pattern: ".+"
|
||||
properties: [name]
|
||||
mode: substring
|
||||
```
|
||||
|
||||
Match predicates are ANDed together. The pattern predicate here says
|
||||
"the resource's `name` property must match the regex `.+` (i.e. non-empty)
|
||||
as a substring". Since every Bucket has a name, every Bucket matches.
|
||||
|
||||
You can filter more aggressively if you want. Examples:
|
||||
|
||||
```yaml
|
||||
# Only buckets whose name starts with "prod-"
|
||||
- type: pattern
|
||||
pattern: "^prod-"
|
||||
properties: [name]
|
||||
mode: substring
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Only buckets that carry the label env=production
|
||||
- type: exists
|
||||
path: "resource/metadata/labels/env"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Combine — buckets in the prod GCP project label AND with a Ready condition entry
|
||||
- type: and
|
||||
matches:
|
||||
- type: pattern
|
||||
pattern: "^runwhen-prod-.*"
|
||||
properties: [name]
|
||||
mode: substring
|
||||
- type: exists
|
||||
path: "resource/status/conditions"
|
||||
```
|
||||
|
||||
### `slxs`
|
||||
|
||||
```yaml
|
||||
slxs:
|
||||
- baseName: xp-bkt-hlth
|
||||
qualifiers: ["resource", "cluster"]
|
||||
baseTemplateName: gcp-bucket-crossplane-health
|
||||
levelOfDetail: detailed
|
||||
outputItems:
|
||||
- type: slx
|
||||
- type: sli
|
||||
- type: runbook
|
||||
templateName: gcp-bucket-crossplane-health-taskset.yaml
|
||||
```
|
||||
|
||||
- `baseName` — short identifier appended to the qualifier prefix to form
|
||||
the final `slx_name`. Keep it under 15 characters (`xp-bkt-hlth` is 11).
|
||||
- `qualifiers` — determines both the **generated SLX name** and how
|
||||
many SLXs are produced.
|
||||
- Including `resource` means "one SLX per matching Bucket".
|
||||
- Including `cluster` prefixes the name with the cluster name so it is
|
||||
stable across multi-cluster workspaces.
|
||||
- **Not including `namespace`** — Crossplane managed resources are
|
||||
cluster-scoped; they have no namespace.
|
||||
- `baseTemplateName` — the file-name stem for the templates. The output
|
||||
items resolve to `<baseTemplateName>-<type>.yaml` unless overridden.
|
||||
So `type: slx` looks up `gcp-bucket-crossplane-health-slx.yaml`,
|
||||
`type: sli` → `-sli.yaml`, etc.
|
||||
- `levelOfDetail` — one of `none | basic | detailed` (or `0|1|2`). This
|
||||
gates whether the SLX is emitted based on the platform's LOD filter.
|
||||
Since our airgap `defaultLOD` is `detailed`, we set the SLX to
|
||||
`detailed` so it always renders.
|
||||
- `outputItems` — one entry per artifact we want. `type: runbook`
|
||||
specifies an explicit `templateName` because we want to point at a
|
||||
file named `-taskset.yaml`, not `-runbook.yaml`, purely for convention
|
||||
matching what other RunWhen bundles do.
|
||||
|
||||
## What resource identity ends up in the SLX
|
||||
|
||||
Given a Bucket named `runwhen-nonprod-shared-litellm-logging` on cluster
|
||||
`shared-cluster`, the rule will produce an SLX whose name is roughly:
|
||||
|
||||
```
|
||||
shared-cluster-runwhen-nonprod-shared-litellm-logging-xp-bkt-hlth
|
||||
```
|
||||
|
||||
RunWhen Local will *also* shorten and hash-suffix pieces as needed for
|
||||
length. The exact rendering happens in
|
||||
[`generation_rules.py::make_slx_name`](https://github.com/runwhen-contrib/runwhen-local/blob/main/src/enrichers/generation_rules.py).
|
||||
|
||||
## Screenshot placeholders
|
||||
|
||||
- `images/04-generation-rule-annotated.png` — the YAML above with arrows
|
||||
pointing to the resource type, match rule, and output items.
|
||||
|
||||
## Next
|
||||
|
||||
Continue to [05 — SLX and templates](05-slx-and-templates.md).
|
||||
@@ -0,0 +1,178 @@
|
||||
# 05 — SLX and templates
|
||||
|
||||
## What you'll do
|
||||
|
||||
Read the three Jinja templates that render the artifacts for every
|
||||
discovered Bucket.
|
||||
|
||||
## The three templates
|
||||
|
||||
| Output type | Template file | Kind produced |
|
||||
|-------------|---------------|---------------|
|
||||
| `slx` | `gcp-bucket-crossplane-health-slx.yaml` | `ServiceLevelX` |
|
||||
| `sli` | `gcp-bucket-crossplane-health-sli.yaml` | `ServiceLevelIndicator` |
|
||||
| `runbook` | `gcp-bucket-crossplane-health-taskset.yaml` | `Runbook` |
|
||||
|
||||
## The SLX template
|
||||
|
||||
[`gcp-bucket-crossplane-health-slx.yaml`](../codebundles/gcp-bucket-crossplane-health/.runwhen/templates/gcp-bucket-crossplane-health-slx.yaml)
|
||||
|
||||
Key sections:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
alias: Crossplane GCP Bucket {{match_resource.resource.metadata.name}} Health
|
||||
asMeasuredBy: Fraction of Crossplane .status.conditions[] that are True (Ready + Synced).
|
||||
configProvided:
|
||||
- name: OBJECT_NAME
|
||||
value: {{match_resource.resource.metadata.name}}
|
||||
- name: API_GROUP
|
||||
value: storage.gcp.upbound.io
|
||||
- name: KIND
|
||||
value: Bucket
|
||||
```
|
||||
|
||||
`configProvided` on the SLX itself is displayed in the UI as
|
||||
"configured properties" of the service. It's not fed to Robot code —
|
||||
that's what the SLI and Runbook `configProvided` blocks are for. Use it
|
||||
for values that describe the *thing being monitored*, not values passed
|
||||
to the *robot doing the monitoring*.
|
||||
|
||||
The template also uses two shared includes shipped by workspace-builder:
|
||||
|
||||
```yaml
|
||||
additionalContext:
|
||||
{% include "kubernetes-hierarchy.yaml" ignore missing %}
|
||||
qualified_name: "{{ match_resource.qualified_name }}"
|
||||
tags:
|
||||
{% include "kubernetes-tags.yaml" ignore missing %}
|
||||
- name: access
|
||||
value: read-only
|
||||
```
|
||||
|
||||
- **`kubernetes-hierarchy.yaml`** — emits the `hierarchy: [platform, cluster,
|
||||
resource_name]` list used to build the SLX's UI breadcrumb.
|
||||
- **`kubernetes-tags.yaml`** — emits `platform`, `cluster`, `resource_type`,
|
||||
`resource_name`, and every Kubernetes label as a `[k8s]<key>` tag. It
|
||||
also emits `resource_type: bucket` because
|
||||
`match_resource.resource_type.name` is `custom` for CRDs (the include
|
||||
handles that special case).
|
||||
|
||||
## The SLI template
|
||||
|
||||
[`gcp-bucket-crossplane-health-sli.yaml`](../codebundles/gcp-bucket-crossplane-health/.runwhen/templates/gcp-bucket-crossplane-health-sli.yaml)
|
||||
|
||||
The most important line is the `codeBundle` block:
|
||||
|
||||
```yaml
|
||||
codeBundle:
|
||||
repoUrl: http://rw-airgap-cc-catalog-svc.runwhen-env-airgap:8080/git/rw-generic-codecollection.git
|
||||
ref: main
|
||||
pathToRobot: codebundles/k8s-kubectl-cmd/sli.robot
|
||||
```
|
||||
|
||||
`repoUrl` deliberately points at the airgap cc-catalog proxy, not at
|
||||
this private CC and not at public GitHub. That's how the runner reaches
|
||||
`rw-generic-codecollection` — it's the URL the runner already trusts and
|
||||
mirrors.
|
||||
|
||||
> **If you move this example to another environment**, change this URL
|
||||
> to wherever the generic collection is served: public GitHub for
|
||||
> internet-connected environments, a public JCR / mirror otherwise.
|
||||
|
||||
The `configProvided` block wires up `k8s-kubectl-cmd`'s expected inputs:
|
||||
|
||||
```yaml
|
||||
configProvided:
|
||||
- name: TASK_TITLE
|
||||
value: 'Crossplane GCP Bucket {{match_resource.resource.metadata.name}} condition health'
|
||||
- name: KUBECTL_COMMAND
|
||||
value: |
|
||||
kubectl get buckets.storage.gcp.upbound.io {{match_resource.resource.metadata.name}} -o json | jq -r '(.status.conditions // []) as $c | if ($c|length)==0 then 0 else ([$c[]|select(.status=="True")]|length)/($c|length) end'
|
||||
- name: TIMEOUT_SECONDS
|
||||
value: '120'
|
||||
```
|
||||
|
||||
The `jq` breakdown:
|
||||
|
||||
| Fragment | Meaning |
|
||||
|----------|---------|
|
||||
| `(.status.conditions // []) as $c` | Save the conditions array (or empty array if missing) into `$c` |
|
||||
| `if ($c\|length)==0 then 0` | No conditions yet → return 0 (unhealthy). Signals "the resource has not reconciled". |
|
||||
| `else ([$c[]\|select(.status=="True")]\|length) / ($c\|length)` | Otherwise fraction of True conditions over total |
|
||||
|
||||
`RW.Core.Push Metric ${rsp.stdout}` inside the generic SLI robot takes
|
||||
that value (a JSON number between 0 and 1) and pushes it to the platform.
|
||||
|
||||
Finally the secrets block:
|
||||
|
||||
```yaml
|
||||
secretsProvided:
|
||||
{% if wb_version %}
|
||||
{% include "kubernetes-auth.yaml" ignore missing %}
|
||||
{% else %}
|
||||
- name: kubeconfig
|
||||
workspaceKey: {{custom.kubeconfig_secret_name}}
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
`wb_version` is set only by newer workspace-builders. This lets the
|
||||
same template render correctly against both old and new runners. The
|
||||
`kubeconfig_secret_name` value comes from the `custom:` block in your
|
||||
runner's `workspaceInfo.yaml` (which we don't need to change — the
|
||||
existing airgap runner already provides
|
||||
`k8s:file@secret/kubeconfig:kubeconfig`).
|
||||
|
||||
## The TaskSet template
|
||||
|
||||
[`gcp-bucket-crossplane-health-taskset.yaml`](../codebundles/gcp-bucket-crossplane-health/.runwhen/templates/gcp-bucket-crossplane-health-taskset.yaml)
|
||||
|
||||
Same shape as the SLI, but pointing at `runbook.robot` and with a
|
||||
bigger `jq` expression that emits the JSON issue envelope:
|
||||
|
||||
```yaml
|
||||
- name: ISSUE_JSON_QUERY_ENABLED
|
||||
value: 'true'
|
||||
- name: ISSUE_JSON_TRIGGER_KEY
|
||||
value: issuesIdentified
|
||||
- name: ISSUE_JSON_TRIGGER_VALUE
|
||||
value: 'true'
|
||||
- name: ISSUE_JSON_ISSUES_KEY
|
||||
value: issues
|
||||
```
|
||||
|
||||
These four variables are how `k8s-kubectl-cmd/runbook.robot` decides
|
||||
whether to parse the stdout as JSON. When enabled, it looks for an
|
||||
object like:
|
||||
|
||||
```json
|
||||
{
|
||||
"issuesIdentified": true,
|
||||
"issues": [
|
||||
{
|
||||
"title": "...",
|
||||
"severity": 2,
|
||||
"expected": "...",
|
||||
"actual": "...",
|
||||
"reproduce_hint": "...",
|
||||
"next_steps": "...",
|
||||
"details": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Our `KUBECTL_COMMAND` produces exactly that shape from
|
||||
`.status.conditions[]`. One issue per condition that is not `True`.
|
||||
Ready failures are severity 2, others (like Synced) are severity 3.
|
||||
|
||||
## Screenshot placeholders
|
||||
|
||||
- `images/05-rendered-slx.png` — a rendered SLX YAML in the RunWhen UI
|
||||
(from the SLX detail view).
|
||||
- `images/05-tags-and-hierarchy.png` — the SLX detail sidebar showing
|
||||
the hierarchy and tags produced by the shared includes.
|
||||
|
||||
## Next
|
||||
|
||||
Continue to [06 — Runner integration](06-runner-integration.md).
|
||||
@@ -0,0 +1,120 @@
|
||||
# 06 — Runner integration
|
||||
|
||||
## What you'll do
|
||||
|
||||
Tell the airgap `workspace-builder` where to find this repository and
|
||||
have Flux reconcile the change.
|
||||
|
||||
## The single edit
|
||||
|
||||
Open the airgap runner's HelmRelease values file:
|
||||
|
||||
`apps/runwhen-env/airgap/runwhen-runner/helm.yaml`
|
||||
|
||||
Under `spec.values.workspaceBuilder.workspaceInfo.configMap.data.codeCollections`,
|
||||
append one entry. Before:
|
||||
|
||||
```yaml
|
||||
codeCollections:
|
||||
- repoURL: http://rw-airgap-cc-catalog-svc.runwhen-env-airgap:8080/git/rw-cli-codecollection.git
|
||||
ref: main
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```yaml
|
||||
codeCollections:
|
||||
- repoURL: http://rw-airgap-cc-catalog-svc.runwhen-env-airgap:8080/git/rw-cli-codecollection.git
|
||||
ref: main
|
||||
- repoURL: https://<gitea-token>@gitea.airgap.shared.runwhen.com/gitea-admin/simple-private-codecollection.git
|
||||
ref: main
|
||||
```
|
||||
|
||||
The `<gitea-token>` value is a Gitea personal access token with at least
|
||||
`read:repository` scope on the `simple-private-codecollection` repo.
|
||||
|
||||
Screenshot placeholder: `images/06-helm-yaml-diff.png` — annotated diff of
|
||||
the two-line addition.
|
||||
|
||||
## What we intentionally did NOT change
|
||||
|
||||
- `runner.codeCollections` — the list of code collections whose Robot
|
||||
code is executed by worker pods. Because our private CC ships **no
|
||||
robots**, it never needs a runner worker. The runtime lives in the
|
||||
already-registered `rw-generic-codecollection`.
|
||||
- Any secret configuration — we are using the fastest working option:
|
||||
the token embedded directly in the `repoURL`. This trades tidiness
|
||||
for zero setup complexity. See "Trade-offs" below.
|
||||
|
||||
## Trade-offs of token-in-URL (option B)
|
||||
|
||||
The token becomes visible to anyone with read access to the
|
||||
`workspace-builder` `ConfigMap` — via `kubectl get cm workspace-builder
|
||||
-o yaml` in the runner namespace, and to anyone reading the Flux repo.
|
||||
That is acceptable for a **test** token you can rotate, but not
|
||||
production hygiene.
|
||||
|
||||
Two better options once this example is working:
|
||||
|
||||
### Option A — cc-catalog proxy (production path)
|
||||
|
||||
Turn on runtime git sync in the platform-side cc-catalog service and add
|
||||
a `simple-private-codecollection` slug there. The workspace-builder then
|
||||
clones from the internal proxy URL (no token in ConfigMap):
|
||||
|
||||
```yaml
|
||||
- repoURL: http://rw-airgap-cc-catalog-svc.runwhen-env-airgap:8080/git/simple-private-codecollection.git
|
||||
ref: main
|
||||
```
|
||||
|
||||
That requires editing `apps/runwhen-env/airgap/runwhen-platform/helmrelease.yaml`
|
||||
to (a) add the slug under `ccCatalog.config.sources[].codecollections`
|
||||
and (b) enable `git.runtime_sync: true` with `git.auth.token_env`
|
||||
pointing at a Kubernetes secret storing the Gitea token. Track that as a
|
||||
follow-up.
|
||||
|
||||
### Option C — fix upstream
|
||||
|
||||
Patch `runwhen-local` to actually apply `authUser`/`authToken` in
|
||||
[`code_collection.py`](https://github.com/runwhen-contrib/runwhen-local/blob/main/src/enrichers/code_collection.py)
|
||||
`update_repo()`. Today those fields are parsed and stored on the
|
||||
`CodeCollection` object but never applied to `Repo.clone_from()`. A
|
||||
one-line fix to route through `git_utils.get_repo_url_with_auth()`. Then
|
||||
you can pass `authUser`/`authToken` in `workspaceInfo.yaml` and reference
|
||||
them as environment variables or literal values without leaking the
|
||||
token.
|
||||
|
||||
## Trigger the change
|
||||
|
||||
The Flux `HelmRelease` for `runwhen-local` reconciles every 5 minutes,
|
||||
but you can force it:
|
||||
|
||||
```bash
|
||||
flux reconcile hr runwhen-local -n runwhen-env-airgap-runner --with-source
|
||||
```
|
||||
|
||||
Watch for the reconcile to complete:
|
||||
|
||||
```bash
|
||||
kubectl -n runwhen-env-airgap-runner get pods -w
|
||||
```
|
||||
|
||||
You should see the `runwhen-local` pod restart (because the ConfigMap
|
||||
changed). Its next start will use the new `workspaceInfo`.
|
||||
|
||||
## Verify the workspace-builder can reach Gitea
|
||||
|
||||
```bash
|
||||
kubectl -n runwhen-env-airgap-runner exec deploy/runwhen-local -c workspace-builder -- \
|
||||
curl -sSI --max-time 5 https://gitea.airgap.shared.runwhen.com/ | head -1
|
||||
```
|
||||
|
||||
Expected: `HTTP/2 200` or `HTTP/1.1 200 OK`. If the request hangs, check
|
||||
`NetworkPolicy` — the airgap deps namespace should already permit this
|
||||
traffic on 443.
|
||||
|
||||
Screenshot placeholder: `images/06-curl-gitea.png`.
|
||||
|
||||
## Next
|
||||
|
||||
Continue to [07 — Validate and observe](07-validate-and-observe.md).
|
||||
@@ -0,0 +1,144 @@
|
||||
# 07 — Validate and observe
|
||||
|
||||
## What you'll do
|
||||
|
||||
Confirm end-to-end that:
|
||||
|
||||
1. The generation rule is schema-valid.
|
||||
2. Workspace-builder clones the private CC from Gitea.
|
||||
3. One SLX is rendered per Crossplane Bucket in the cluster.
|
||||
4. The RunWhen Platform receives the SLXs and the SLI + TaskSet run.
|
||||
|
||||
## Step 1 — Local schema validation
|
||||
|
||||
If you have `runwhen-local` checked out, you can validate before pushing:
|
||||
|
||||
```bash
|
||||
cd /path/to/runwhen-local/src
|
||||
python3 validate_generation_rules.py /path/to/simple-private-codecollection -v
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```
|
||||
No validation errors found.
|
||||
```
|
||||
|
||||
Or a one-liner without cloning `runwhen-local`:
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import json, yaml, jsonschema, urllib.request
|
||||
schema = json.loads(urllib.request.urlopen(
|
||||
'https://raw.githubusercontent.com/runwhen-contrib/runwhen-local/main/src/generation-rule-schema.json'
|
||||
).read())
|
||||
with open('codebundles/gcp-bucket-crossplane-health/.runwhen/generation-rules/gcp-bucket-crossplane-health.yaml') as f:
|
||||
jsonschema.validate(yaml.safe_load(f), schema)
|
||||
print('OK')
|
||||
"
|
||||
```
|
||||
|
||||
Screenshot placeholder: `images/07-schema-validation.png`.
|
||||
|
||||
## Step 2 — Push to Gitea
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Initial codecollection: Crossplane GCP Bucket rules-only example"
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
Refresh the Gitea repo URL in a browser — you should see the file tree.
|
||||
|
||||
Screenshot placeholder: `images/07-gitea-repo.png` — Gitea file browser
|
||||
showing `codebundles/` and `docs/`.
|
||||
|
||||
## Step 3 — Reconcile Flux
|
||||
|
||||
```bash
|
||||
flux reconcile hr runwhen-local -n runwhen-env-airgap-runner --with-source
|
||||
```
|
||||
|
||||
## Step 4 — Watch workspace-builder logs
|
||||
|
||||
```bash
|
||||
kubectl -n runwhen-env-airgap-runner logs \
|
||||
-l runwhen.com/component=workspace-builder \
|
||||
-c workspace-builder --tail=200 -f
|
||||
```
|
||||
|
||||
Look for lines like:
|
||||
|
||||
- `Cloning from git source: https://.../simple-private-codecollection.git`
|
||||
- `Loading generation rules from ...gcp-bucket-crossplane-health.yaml`
|
||||
- `Rendered N SLXs` (where N is the number of Buckets)
|
||||
|
||||
Common errors and their meaning:
|
||||
|
||||
| Error | Cause |
|
||||
|-------|-------|
|
||||
| `fatal: Authentication failed` on clone | Token in URL missing, expired, or lacks read scope |
|
||||
| `KeyError: 'match_resource'` in a template | Jinja variable typo, or trying to use a variable that isn't populated for the platform |
|
||||
| `Validation failed for ... generation rule ... required "outputItems"` | Schema drift — check the JSON schema |
|
||||
| `No matches for resource type buckets.storage.gcp.upbound.io/v1beta1` | The CRD isn't installed on this cluster, or the plural is wrong |
|
||||
| `Forbidden ... cannot list buckets.storage.gcp.upbound.io` | Missing RBAC for `workspace-builder` SA |
|
||||
|
||||
Screenshot placeholder: `images/07-workspace-builder-logs.png`.
|
||||
|
||||
## Step 5 — Inspect rendered SLXs in the runner filesystem
|
||||
|
||||
`workspace-builder` writes the rendered artifacts to disk before
|
||||
uploading them. Peek at one:
|
||||
|
||||
```bash
|
||||
kubectl -n runwhen-env-airgap-runner exec deploy/runwhen-local \
|
||||
-c workspace-builder -- \
|
||||
find /shared/output -type f | grep xp-bkt-hlth | head -5
|
||||
```
|
||||
|
||||
Then read one:
|
||||
|
||||
```bash
|
||||
kubectl -n runwhen-env-airgap-runner exec deploy/runwhen-local \
|
||||
-c workspace-builder -- \
|
||||
cat /shared/output/output/slx/<name>/sli.yaml
|
||||
```
|
||||
|
||||
Confirm the `codeBundle.repoUrl` points at the internal
|
||||
`rw-generic-codecollection` proxy and the `KUBECTL_COMMAND` includes
|
||||
the bucket name.
|
||||
|
||||
## Step 6 — See it in the RunWhen UI
|
||||
|
||||
Sign into the RunWhen workspace UI:
|
||||
`https://app.airgap.shared.runwhen.com/map/self`
|
||||
|
||||
You should see one SLX per Bucket under the `shared-cluster` hierarchy.
|
||||
Click into one — the SLI panel should already be graphing a value near
|
||||
`1.0` (assuming buckets are healthy). Trigger the TaskSet from the SLX
|
||||
detail page and confirm the report includes `Command stdout` showing
|
||||
the JSON envelope, and any non-True conditions become issues.
|
||||
|
||||
Screenshot placeholders:
|
||||
|
||||
- `images/07-runwhen-map.png` — the map view with the new SLXs visible.
|
||||
- `images/07-sli-graph.png` — the SLI panel graphing 1.0.
|
||||
- `images/07-taskset-report.png` — the TaskSet execution report.
|
||||
- `images/07-issue-detail.png` — a rendered issue (deliberately break a
|
||||
Bucket by editing the Crossplane spec to force a `Synced=False`
|
||||
condition, so we can capture what an unhealthy report looks like).
|
||||
|
||||
## Iteration checklist
|
||||
|
||||
- [ ] Iteration 1: end-to-end plumbing works, SLXs are generated,
|
||||
SLI graphs are populated. **(You are here.)**
|
||||
- [ ] Iteration 2: screenshots captured and inlined in these chapters.
|
||||
- [ ] Iteration 3: prose polish, `.test/` harness for local
|
||||
`runwhen-local` container validation, migration to Option A
|
||||
(cc-catalog proxy) for production hygiene.
|
||||
|
||||
## Where to file feedback
|
||||
|
||||
If something in these docs is confusing or wrong, edit the markdown
|
||||
directly and push. This is a living training guide — improvements are
|
||||
expected.
|
||||
@@ -0,0 +1,2 @@
|
||||
# Placeholder so an empty images/ directory is tracked in git.
|
||||
# Delete this file once real screenshots (PNGs) have been added.
|
||||
Reference in New Issue
Block a user