Repository navigation
Nothing alerts on an unhealthy Crossplane provider fleet, so a total outage read green for 18 hoursΒ #3457
Description
π€ Generated by the Agentic Engineer
Evidence
A total Crossplane outage ran for more than 18 hours while every surface a run inspects read green.
#3454 went live at the 2.3.4 β 2.4.0 core bump: a duplicate Provider for one package source poisoned the Lock's dependency graph, and every provider in the cluster went Healthy=False. Measured live during the incident:
- All 5
ProviderobjectsHealthy=False, each with the same message βcannot initialize dependency graph from the packages in the lock: node xpkg.upbound.io/upbound/provider-family-aws already exists. - Zero provider pods in
crossplane-systemβ onlycrossplaneandcrossplane-rbac-managerwere running. - Live AWS managed resources depending on that fleet:
role/eks-ci,policy/eks-ci-smoke-boundary, andopenidconnectprovider/github-actionsβ the last of which is what lets GitHub Actions authenticate to AWS at all.
And simultaneously, throughout:
flux-system/infrastructureβ the Kustomization that owns those Providers β reportedReady=True, "Applied revision: β¦".- Every GitHub surface was green. Two consecutive scheduled runs surveyed the portfolio and recorded
nothing_on_fire.
The outage was found only because a run happened to query the cluster directly.
Why the existing coverage does not reach this
docs/dr/alerting.md states the design: the four top-level Kustomizations wait: true on their children, "so a failed controller / app / HelmRelease surfaces here as its parent going NotReady". That is exactly what did not happen. wait: true health-gates on kstatus, and kstatus has no readiness rule for a Crossplane Provider β the same gap infrastructure's own comment already documents for KRO's ResourceGraphDefinition and works around with an explicit healthCheckExprs entry. With no rule, a Provider is not treated as unready; it is simply not gated at all.
So the parent never goes NotReady, and the documented escalation path never fires. This is the same class as #3099 (nothing alerts on a stuck Longhorn volume) and the CNPG gap that cron-job-cnpg-degraded-alert was built for β a resource whose degradation no existing signal covers.
Coroot's auto-inspections do not cover it either: there is no crashloop and no restart. There are simply no pods, and "a Deployment that should exist but does not" is not a thing an inspection on a running workload can see.
Hypothesis
An independent check that reads Provider health directly, on the same CronJob pattern as cnpg-degraded-alert, turns an 18-hour silent outage into a page within one tick.
Success signal
- Baseline: time-to-detection for All Crossplane providers unhealthy: Crossplane 2.4.0 auto-created a duplicate provider-family-aws, poisoning the lock dependency graphΒ #3454 was ~15 hours to first human-visible signal, and it was incidental rather than produced by any monitor.
- Target: a provider that has been
Healthy=Falsepast the grace period pages within one CronJob interval. - Guardrail: no alert during a normal provider upgrade, when a provider is briefly unhealthy while its new revision installs.
Smallest useful change
A crossplane-provider-unhealthy-alert CronJob in observability, modelled directly on k8s/bases/infrastructure/controllers/coroot/cron-job-cnpg-degraded-alert.yaml:
- lists
providers.pkg.crossplane.iothrough the API server with a narrowly-scoped SA; - alerts on any
ProviderwhoseHealthycondition has beenFalselonger than a grace period; - reuses the
allow-corootCiliumNetworkPolicy and the same SOPS-substituted webhook, and skips the reservedexample.invalidhost so local and CI stay quiet; - carries
kustomize.toolkit.fluxcd.io/substitute: disabled, for the same reason that CronJob does β an unescaped${GRACE_SECONDS}is why the CNPG check had never once succeeded.
π΄ The grace period needs measuring first, and that CronJob's header says so
Its own comment warns explicitly:
Crossplane's
Syncedcondition behaves the OPPOSITE way β it is rewritten every reconcile, which would make a grace period silently unreachable and the check a permanent no-op. Do not copy this pattern to a Crossplane resource without re-measuring.
That warning is about Synced on a managed resource. This check keys on Healthy on a Provider, which is a different condition on a different kind, so the warning neither applies nor is safely assumed away β it has to be measured.
First measurement, taken live during the incident (2026-08-29T15:09:53Z): all five Providers carried Healthy=False with lastTransitionTime stable at 14:51:40β14:51:42Z, i.e. unchanged for ~18 minutes across an active reconcile loop. That is consistent with a real transition timestamp rather than a per-reconcile rewrite, but it is one sample. Confirm with the sampling method that CronJob's header records β two samples ~45s apart, checking that metadata.resourceVersion advances while lastTransitionTime does not β and take it against a healthy fleet as well, since a stuck provider may simply not be reconciling.
If the timestamp turns out to be rewritten, key the grace period on observed state across ticks instead, and say so in the manifest.
Acceptance criteria
- The
Healthy-conditionlastTransitionTimebehaviour on aProvideris measured (both a healthy and an unhealthy fleet), and the result recorded in the manifest's header. - A CronJob alerts on any
Providerunhealthy past the grace period, independent of every Flux health gate β so relaxing a gate cannot silently remove this coverage. - Proven in both directions against the real API shape: an unhealthy-past-grace fleet alerts, and a provider mid-upgrade inside the grace does not.
- The delivery path is proven β an undeliverable webhook fails the Job rather than dropping the alert silently, matching
cnpg-degraded-alert. -
example.invalidis skipped, so local and CI stay quiet by design.
Explicitly out of scope
Adding a Provider entry to flux-system/infrastructure's healthCheckExprs. It looks like the obvious fix and it is the wrong one here: #3100 documents, with two incidents, that making that Kustomization strict wedges the merge queue for the whole platform. Detection belongs on an independent alert, exactly as cnpg-degraded-alert's header argues.
Rough size: small β one manifest plus its kustomization entry and tests, closely following an existing file.
Refinement (2026-10-17)
Moved from Icebox to Ready: #4981 names this as its smallest useful change, and it has the longest measured silence of any class there.
State on main today. No manifest reads providers.pkg.crossplane.io health. The pattern to copy is still k8s/bases/infrastructure/controllers/coroot/cron-job-cnpg-degraded-alert.yaml with its own ClusterRole and ClusterRoleBinding; two later alerts (cron-job-posture-staleness-alert.yaml, cron-job-stranded-volume-attach-alert.yaml) follow the same shape and can be read for the current conventions.
The first acceptance criterion is answered, with one check left. The 2026-08-29 comment shows from crossplane-runtime source that Condition.Equal ignores LastTransitionTime and SetConditions skips an equal write, so Healthy's transition time is real and a grace period keyed on it is reachable. That was read for the version running then. Before building, re-read the same two functions in the crossplane-runtime release used by the Crossplane version production runs now (from the released module source), and record the version in the manifest header. If they changed, key the grace period on state observed across ticks instead, as the body says.
Decisions kept: independent CronJob, not a healthCheckExprs entry on flux-system/infrastructure (see Explicitly out of scope); the RBAC grants get and list on providers.pkg.crossplane.io only; the alert names the provider and its condition reason, never package credentials or account identifiers.
Priority High, Effort Low, Service class Risk reduction. An unnoticed provider outage stops the platform's AWS resources from reconciling, including what lets GitHub Actions authenticate to AWS, and the measured silence was 18 hours. One manifest set closely following three existing ones, plus its tests and a controlled proof in both directions. No prerequisites. Part of #4981.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Start date
Target date
Effort
Projects
- StatusShow more project fieldsβ Done
Activity
devantler commentedon Aug 29, 2026
Two measurements taken while the fleet was still down
1. The existing Crossplane alerting stack does not cover this, and cannot
crossplane-sync-exporter+cron-job-crossplane-sync-alerteralready exist and are well built β liveness is part of the alert, coverage is compared against livemanaged-category CRDs, and an empty result is explicitly refused as good news. But they observeSyncedon managed resources, which is a different failure than a provider package that never installed.Measured live during the outage, ~18 hours in, with zero provider pods running:
Synced=True, last written ~46 hours before the sample β before the outage began. With no controller running, nothing flips it toFalse; the condition simply stops moving. So the exporter publishesSynced=True, the alerter finds noSynced=Falsesamples,up{job="crossplane-sync-exporter"}is healthy because the exporter itself is fine, and the coverage check passes because the GVKs are all still present as CRDs.Every guard in that design holds, and the fleet is dead. The condition it watches is written by the thing that died, which is the one shape liveness-of-the-sensor cannot catch. That is the gap this issue is about, and it is why the fix belongs on
Providerhealth rather than as an extension of the sync alerter.2. The grace-period measurement is INCONCLUSIVE β do not treat sample 1 as settled
Two samples of all five
Providerobjects, 179 seconds apart (15:09:53Z β 15:12:52Z):HealthylastTransitionTimemetadata.resourceVersionresourceVersiondid not move, so the objects were not written at all between the samples. A stablelastTransitionTimeacross two samples of an object nobody wrote is not evidence that the timestamp survives a reconcile β it is evidence that no reconcile happened. This is exactly the trapcron-job-cnpg-degraded-alert.yaml's header warns about, arriving from the other direction: there the risk was a timestamp rewritten too often, here it is a sample taken while the writer is dead.The measurement in the issue body's Smallest useful change section therefore still stands open, and must be taken against a healthy, actively reconciling fleet: confirm
resourceVersionadvances whileHealthy'slastTransitionTimedoes not. Until that is done, the grace period is unproven.devantler commentedon Aug 29, 2026
The grace-period measurement is answered from the controller's source β no healthy fleet required
This issue is blocked on measuring whether
Healthy'slastTransitionTimeon aProvideris a real transition timestamp or is rewritten every reconcile. That question is decided bycrossplane-runtime, and the answer is unambiguous: it is a real transition timestamp, so a grace period keyed on it is reachable.Condition.Equaldeliberately excludesLastTransitionTimefrom the comparison β the doc comment says so outright:and
SetConditionsskips the write entirely whenEqualholds, leaving the stored condition β and therefore its originalLastTransitionTimeβ in place:Verified identical in both
crossplane-runtime/v2@v2.1.0andv2@v2.2.1(v2.2.1 only adds the zero-guard onObservedGeneration). The condition constructors do stampmetav1.Now(), but that value is discarded wheneverEqualis true, which is why a steady-state condition does not move.This is consistent with the live sample already recorded here β all 5 Providers stable at 14:51:40β42Z across ~18 minutes β and it explains why that sample was trustworthy in a way the two-sample method could not establish on its own (
resourceVersionwas byte-identical, so nothing had been written).This also corrects the warning the plan was deferring to
cron-job-cnpg-degraded-alert.yaml's header says Crossplane'sSynced"is rewritten every reconcile". By the code above that cannot be a property of Crossplane conditions in general β an unchanged condition is never restamped, whatever its type. IfSyncedreally does move per reconcile, the cause must be that itsMessagevaries between reconciles, which makesEqualfalse and replaces the whole condition.That reframing matters for this check's design, and it generalises better than the original warning:
ReasonandMessagehold steady while the object is actively being written".Healthy=Trueon aProvideris a fixed reason with no varying payload, which is the state the guardrail cares about (no alert while a provider is briefly unhealthy during an upgrade).Residual, stated honestly
The source settles the mechanism; it does not by itself prove that the package reconciler writes a stable message. The remaining check is therefore much narrower than the original blocker β confirm
Reason/Messageare unchanging across writes on a reconciling fleet, rather than re-measure the timestamp semantics. That can ride along with the first healthy-fleet observation after #3454 is resolved and does not need to gate authoring the CronJob.Recommendation: drop the
blockedstate on the grace-period question, build the check againstHealthy'slastTransitionTimeas originally designed, and keep the narrowed message-stability confirmation as an acceptance check before the alert is armed.chore: agent-claim #3457 nonce=beb67b63f7c6d1fcd25cb100e5d19eca
chore: agent-claim #3457 nonce=47d97033a935140cc62159b8c393ff1c