Skip to content

Commit dcb83b0

Browse files
TehreemNisaclaude
andcommitted
fix(crossplane-observability): derive the provider job label from the PodMonitor the chart ships
`crossplane.providers.job` defaulted to `crossplane-providers` — a plausible-looking name that no PodMonitor can ever produce. Prometheus-operator derives a PodMonitor's job label as `<namespace>/<name>`, so the chart's own provider scrape lands under `crossplane-system/<release>-crossplane-observability-providers`, and the 11 rules filtering on `job="crossplane-providers"` matched nothing. Enabling `providerPodMonitor` therefore produced a working scrape and still-empty rules, with nothing anywhere reporting a problem. Fixed the way the core half already works — by deriving the coupling instead of asking two settings to agree. `crossplane.core.job` names the Service, so core is self-consistent by construction; `crossplane.providers.job` now defaults to empty, meaning "the PodMonitor this chart ships", and is computed from the rendered object. An explicit value still wins, which is the case that matters when a platform scrape already covers the provider pods. Guarded so it cannot regress: validate.sh step 1c walks the all-enabled render and asserts every shipped monitor's job label is one the rules actually query — a ServiceMonitor's being the Service name, a PodMonitor's being `<namespace>/<name>`. Confirmed it fails on the old default before the fix. The promtool unit tests render with an explicit job so their fixtures stay independent of the release name; they test rule logic, step 1c tests the wiring. This is the second half of making the addon work without per-cluster values: the saap-catalog wrapper currently carries `job: crossplane-system/crossplane-providers-and-functions`, which describes one cluster's hand-applied PodMonitor and is wrong everywhere else. With this it can drop the override entirely. Refs SA-8539, #283 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 16c1bf7 commit dcb83b0

10 files changed

Lines changed: 99 additions & 14 deletions

File tree

crossplane-observability/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ apiVersion: v2
22
name: crossplane-observability
33
description: A Helm chart for Crossplane observability (ServiceMonitor + PrometheusRule + Grafana dashboard), integrated with OpenShift user-workload monitoring.
44
type: application
5-
version: 0.1.8
5+
version: 0.1.9
66
appVersion: "0.0.1"

crossplane-observability/README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ override:
8484
| Value | Default | Stakater Cloud / typical override |
8585
| --- | --- | --- |
8686
| `crossplane.core.job` | `crossplane` | `crossplane-metrics` |
87-
| `crossplane.providers.job` | `crossplane-providers` | `crossplane-system/crossplane-providers-and-functions` |
87+
| `crossplane.providers.job` | *empty* → derived from the chart's own PodMonitor | only when another scrape already covers the provider pods, e.g. `crossplane-system/crossplane-providers-and-functions` |
8888
| `crossplane.core.serviceMonitorSelector` | `{name:crossplane, component:metrics}` | match your metrics Service labels (only if you enable the chart's monitor) |
8989
| `crossplane.providers.selector` | `pkg.crossplane.io/revision: Exists` | match your provider pods (only if you enable the chart's monitor) |
9090
| `grafana.instanceSelector` | `{app: grafana}` | your instance's labels, e.g. `{dashboards: crossplane}` |
@@ -93,6 +93,11 @@ The `job` values are the important ones — **the alert/recording expressions fi
9393
so if they don't match what your Prometheus assigns, rules evaluate to empty. Find them with
9494
`count by (job)({__name__=~"crossplane_managed_resource_.+"})`.
9595

96+
If you let the chart own the scrape (the `prometheus.monitors.*` toggles above), you do **not**
97+
need to set either job value: the core Service is named after `crossplane.core.job`, and
98+
`crossplane.providers.job` left empty is derived from the PodMonitor the chart renders. Both
99+
couplings are asserted by `./tests/validate.sh`, so they cannot drift apart silently.
100+
96101
**Grafana gotchas (grafana-operator):** to change `grafana.instanceSelector` you must also null
97102
the default key, because Helm deep-merges maps — e.g. `--set grafana.instanceSelector.app=null
98103
--set grafana.instanceSelector.dashboards=crossplane`. And `spec.instanceSelector` is
@@ -219,7 +224,7 @@ These need cluster/Grafana context an agent can't safely guess:
219224
| `crossplane.core.name` | `crossplane` | Crossplane core release/fullname (core ServiceMonitor selector). |
220225
| `crossplane.core.job` | `crossplane` | `job` label core metrics land under (used in alert/recording expressions). |
221226
| `crossplane.providers.selector` | `pkg.crossplane.io/revision: Exists` | Label selector matching provider pods (PodMonitor). |
222-
| `crossplane.providers.job` | `crossplane-providers` | `job` label provider metrics land under. |
227+
| `crossplane.providers.job` | `""` | `job` label provider metrics land under. Empty means "the PodMonitor this chart ships", whose job label prometheus-operator derives as `<namespace>/<name>` — computed rather than restated, since a wrong guess renders fine and matches nothing. Set it only when another scrape already covers the provider pods (and then keep `providerPodMonitor` off). |
223228
| `crossplane.inventory.enabled` | `false` | Enable Claim/inventory rules (needs an exporter — see [example](docs/resource-state-metrics-example.yaml)). |
224229
| `crossplane.inventory.conditionMetricPattern` | `kube_customresource_crossplane_xr_.+_condition` | Regex (PromQL `__name__=~`) matching the exporter's one-hot condition metrics across **all** XR kinds (Story 4.1). |
225230
| `upjet.enabled` | `false` | Upjet providers present — enables Story 6.1 and the more-accurate Upjet TTR (Story 1.2). |

crossplane-observability/templates/_helpers.tpl

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,23 @@ Crossplane runs in, so UWM can resolve the metrics endpoints.
5757
{{- define "crossplane-observability.namespace" -}}
5858
{{- .Values.global.namespace | default .Release.Namespace }}
5959
{{- end }}
60+
61+
{{/*
62+
The `job` label provider metrics carry, used by every provider-scoped rule expression.
63+
64+
Unset means "the PodMonitor this chart ships", whose job label prometheus-operator
65+
derives as `<namespace>/<podmonitor-name>` — so it is computed here rather than
66+
restated in values, because a hand-written guess at that string is wrong in a way
67+
nothing catches: the rules render fine and silently match no series.
68+
69+
Set `crossplane.providers.job` explicitly only when some OTHER scrape already covers
70+
the provider pods (a platform PodMonitor, a differently-relabelled job); then this
71+
chart's own `providerPodMonitor` should stay disabled.
72+
*/}}
73+
{{- define "crossplane-observability.providersJob" -}}
74+
{{- if .Values.crossplane.providers.job -}}
75+
{{- .Values.crossplane.providers.job -}}
76+
{{- else -}}
77+
{{- printf "%s/%s-providers" (include "crossplane-observability.namespace" .) (include "crossplane-observability.fullname" .) -}}
78+
{{- end -}}
79+
{{- end }}

crossplane-observability/templates/prometheus/recording/slo-recording-rules.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{{- if .Values.prometheus.recordingRules.enabled }}
22
{{- $core := .Values.crossplane.core.job }}
3-
{{- $prov := .Values.crossplane.providers.job }}
3+
{{- $prov := include "crossplane-observability.providersJob" . }}
44
# SLO recording rules. Pre-compute the SLIs from the roadmap so error-budget burn is
55
# directly queryable, alert expressions stay readable, and the headline composite SLO
66
# (Story 1.3) is a single series. Recording-rule names follow the Prometheus

crossplane-observability/templates/prometheus/rules/cloud/cloud-api-throttling.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ spec:
2828
# two different API groups isn't merged into one p95.
2929
expr: |
3030
histogram_quantile(0.95, sum by (le, group, kind) (
31-
rate(upjet_resource_reconcile_delay_seconds_bucket{job="{{ .Values.crossplane.providers.job }}"}[15m])
31+
rate(upjet_resource_reconcile_delay_seconds_bucket{job="{{ include "crossplane-observability.providersJob" . }}"}[15m])
3232
)) > {{ $r.thresholdSeconds }}
3333
for: {{ $r.for }}
3434
labels:

crossplane-observability/templates/prometheus/rules/control-plane/detection-lag.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ spec:
2020
- alert: DetectionLagHigh
2121
expr: |
2222
histogram_quantile({{ $r.quantile }}, sum by (le, gvk) (
23-
rate(crossplane_managed_resource_first_time_to_reconcile_seconds_bucket{job="{{ .Values.crossplane.providers.job }}"}[15m])
23+
rate(crossplane_managed_resource_first_time_to_reconcile_seconds_bucket{job="{{ include "crossplane-observability.providersJob" . }}"}[15m])
2424
)) > {{ $r.thresholdSeconds }}
2525
for: {{ $r.for }}
2626
labels:

crossplane-observability/templates/prometheus/rules/drift/drift-detected.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ spec:
3131
# 1h window: drift observations are sparse per gvk (tens per day on some kinds),
3232
# so a short window leaves most kinds with no samples and the alert blind.
3333
expr: |
34-
sum by (gvk) (rate(crossplane_managed_resource_drift_seconds_sum{job="{{ .Values.crossplane.providers.job }}"}[1h]))
34+
sum by (gvk) (rate(crossplane_managed_resource_drift_seconds_sum{job="{{ include "crossplane-observability.providersJob" . }}"}[1h]))
3535
/
36-
sum by (gvk) (rate(crossplane_managed_resource_drift_seconds_count{job="{{ .Values.crossplane.providers.job }}"}[1h]))
36+
sum by (gvk) (rate(crossplane_managed_resource_drift_seconds_count{job="{{ include "crossplane-observability.providersJob" . }}"}[1h]))
3737
> {{ $r.thresholdSeconds }}
3838
for: {{ $r.for }}
3939
labels:

crossplane-observability/templates/prometheus/rules/resource-health/ttr-degraded.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ spec:
2828
histogram_quantile({{ $r.quantile }}, sum by (le, {{ if .Values.upjet.enabled }}group, kind{{ else }}gvk{{ end }}) (
2929
rate(
3030
{{- if .Values.upjet.enabled }}
31-
upjet_resource_ttr_bucket{job="{{ .Values.crossplane.providers.job }}"}[30m]
31+
upjet_resource_ttr_bucket{job="{{ include "crossplane-observability.providersJob" . }}"}[30m]
3232
{{- else }}
33-
crossplane_managed_resource_first_time_to_readiness_seconds_bucket{job="{{ .Values.crossplane.providers.job }}"}[30m]
33+
crossplane_managed_resource_first_time_to_readiness_seconds_bucket{job="{{ include "crossplane-observability.providersJob" . }}"}[30m]
3434
{{- end }}
3535
)
3636
)) > {{ $r.thresholdSeconds }}

crossplane-observability/tests/validate.sh

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
#
66
# Layers (each fails the script on error):
77
# 1. helm lint + template render (Phase 1 defaults AND everything enabled)
8+
# 1c. scrape/job coherence — the `job` label each shipped monitor produces is the one
9+
# the rules actually query (silent-empty-rules guard)
810
# 2. promtool check rules — PromQL parses, rule structure valid
911
# 3. promtool test rules — rule LOGIC fires as intended on synthetic series
1012
# 4. metric gate — every referenced metric is in the allowlist; every
@@ -49,7 +51,7 @@ run_kubeconform() {
4951
}
5052

5153
mkdir -p "$RENDER_DIR"
52-
trap 'rm -f "${RENDER_DIR}/all.yaml"' EXIT
54+
trap 'rm -f "${RENDER_DIR}/all.yaml" "${RENDER_DIR}/all-fixedjob.yaml"' EXIT
5355

5456
step "1. helm lint"
5557
helm lint "$CHART"
@@ -60,8 +62,54 @@ helm template cp "$CHART" >/dev/null && green "renders"
6062
step "1. helm template (everything enabled)"
6163
helm template cp "$CHART" "${ALL_FLAGS[@]}" > "${RENDER_DIR}/all.yaml" && green "renders"
6264

65+
step "1c. scrape/job coherence — the monitors this chart ships match the job labels its rules query"
66+
# Prometheus-operator derives the `job` label from the scrape object, and the rules filter on
67+
# it: a ServiceMonitor's job is the SERVICE name, a PodMonitor's is `<namespace>/<name>`. Get
68+
# either wrong and nothing complains — the rules render, validate, parse, and match no series
69+
# forever. That is exactly how the shipped provider default (`crossplane-providers`, a name no
70+
# PodMonitor can produce) stayed broken. So assert both couplings on the rendered output.
71+
python3 - "${RENDER_DIR}/all.yaml" <<'PY'
72+
import sys, yaml, re
73+
svcs, pms, jobs = [], [], set()
74+
for d in yaml.safe_load_all(open(sys.argv[1])):
75+
if not d:
76+
continue
77+
k = d.get("kind")
78+
if k == "Service":
79+
svcs.append(d["metadata"]["name"])
80+
elif k == "PodMonitor":
81+
pms.append(d["metadata"]["namespace"] + "/" + d["metadata"]["name"])
82+
elif k == "PrometheusRule":
83+
for g in d["spec"]["groups"]:
84+
for r in g["rules"]:
85+
jobs.update(re.findall(r'job="([^"]+)"', r.get("expr", "")))
86+
fail = []
87+
for name in svcs:
88+
if name not in jobs:
89+
fail.append(f'Service/{name} is scraped as job="{name}", which no rule queries')
90+
for job in pms:
91+
if job not in jobs:
92+
fail.append(f'PodMonitor job="{job}" is queried by no rule (crossplane.providers.job disagrees)')
93+
if not svcs or not pms:
94+
fail.append(f"expected a Service and a PodMonitor in the all-enabled render, got {len(svcs)}/{len(pms)}")
95+
if fail:
96+
print("\033[31mscrape/job MISMATCH:\033[0m")
97+
for f in fail:
98+
print(" -", f)
99+
print(" jobs queried by rules:", ", ".join(sorted(jobs)))
100+
sys.exit(1)
101+
print(f"\033[32mjob labels coherent: Service {svcs} + PodMonitor {pms} all queried by rules\033[0m")
102+
PY
103+
63104
step "2/3. merge rendered PrometheusRules for promtool"
64-
python3 - "${RENDER_DIR}/all.yaml" "${RENDER_DIR}/rules.yaml" <<'PY'
105+
# Rendered with an EXPLICIT crossplane.providers.job, unlike all.yaml above: left at the
106+
# default it derives to `<namespace>/<release>-crossplane-observability-providers`, which
107+
# would pin the unit-test fixtures to this script's release name. The unit tests check rule
108+
# LOGIC, not scrape wiring — step 1c owns the wiring — so a stable job label is what they
109+
# want, and pinning it here exercises the override path too.
110+
helm template cp "$CHART" "${ALL_FLAGS[@]}" \
111+
--set crossplane.providers.job=crossplane-providers > "${RENDER_DIR}/all-fixedjob.yaml"
112+
python3 - "${RENDER_DIR}/all-fixedjob.yaml" "${RENDER_DIR}/rules.yaml" <<'PY'
65113
import sys, yaml
66114
src, dst = sys.argv[1], sys.argv[2]
67115
groups = {} # name -> rules[] (merge same-named groups across CRs into one file)

crossplane-observability/values.yaml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,21 @@ crossplane:
4848
selector:
4949
matchExpressions:
5050
- { key: pkg.crossplane.io/revision, operator: Exists }
51-
# The `job` label provider metrics land under once scraped. On Stakater Cloud this is
51+
# The `job` label provider metrics land under once scraped, used by every
52+
# provider-scoped rule expression.
53+
#
54+
# LEAVE EMPTY to use the PodMonitor this chart ships (`providerPodMonitor` above).
55+
# Prometheus-operator derives a PodMonitor's job label as `<namespace>/<name>`, so the
56+
# correct value is computed from the rendered object rather than restated here — a
57+
# hand-written guess at that string is wrong in a way nothing catches: the rules render
58+
# fine and silently match no series. (The previous default, `crossplane-providers`, was
59+
# exactly that: a plausible name no PodMonitor ever produces.)
60+
#
61+
# Set it only when some OTHER scrape already covers the provider pods — then keep
62+
# `providerPodMonitor` disabled so the two don't both scrape. On Stakater Cloud clusters
63+
# carrying the platform PodMonitor that value is
5264
# `crossplane-system/crossplane-providers-and-functions`.
53-
job: crossplane-providers
65+
job: ""
5466
# Story 4.1 — Composite (XR) inventory & readiness. The leaf-MR metrics above are per-GVK
5567
# counts that do NOT cover the Composite/Claim layer the customer experiences. That comes
5668
# from an inventory exporter (resource-state-metrics / ksm-crossplane), which emits a

0 commit comments

Comments
 (0)