Skip to content

Nothing alerts on an unhealthy Crossplane provider fleet, so a total outage read green for 18 hoursΒ #3457

Description

@devantler

πŸ€– 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 Provider objects Healthy=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 β€” only crossplane and crossplane-rbac-manager were running.
  • Live AWS managed resources depending on that fleet: role/eks-ci, policy/eks-ci-smoke-boundary, and openidconnectprovider/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 β€” reported Ready=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

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.io through the API server with a narrowly-scoped SA;
  • alerts on any Provider whose Healthy condition has been False longer than a grace period;
  • reuses the allow-coroot CiliumNetworkPolicy and the same SOPS-substituted webhook, and skips the reserved example.invalid host 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 Synced condition 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-condition lastTransitionTime behaviour on a Provider is measured (both a healthy and an unhealthy fleet), and the result recorded in the manifest's header.
  • A CronJob alerts on any Provider unhealthy 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.invalid is 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.

Activity

  1. moved this to πŸ“₯ Backlog in 🌊 Project Boardon Aug 29, 2026
  2. devantler commented on Aug 29, 2026

    @devantler
    ContributorAuthor

    πŸ€– Generated by the Agentic Engineer

    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-alerter already exist and are well built β€” liveness is part of the alert, coverage is compared against live managed-category CRDs, and an empty result is explicitly refused as good news. But they observe Synced on 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:

    role.iam.aws.m.upbound.io/eks-ci  (namespace aws)
      Ready=True  @2026-07-14T14:59:58Z
      Synced=True @2026-08-27T17:08:55Z  reason=ReconcileSuccess
    

    Synced=True, last written ~46 hours before the sample β€” before the outage began. With no controller running, nothing flips it to False; the condition simply stops moving. So the exporter publishes Synced=True, the alerter finds no Synced=False samples, 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 Provider health 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 Provider objects, 179 seconds apart (15:09:53Z β†’ 15:12:52Z):

    Healthy lastTransitionTime metadata.resourceVersion
    sample 1 14:51:40–14:51:42Z 249646164 / 189 / 214 / 219 / 235
    sample 2 unchanged unchanged β€” byte-identical

    resourceVersion did not move, so the objects were not written at all between the samples. A stable lastTransitionTime across 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 trap cron-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 resourceVersion advances while Healthy's lastTransitionTime does not. Until that is done, the grace period is unproven.

  3. devantler commented on Aug 29, 2026

    @devantler
    ContributorAuthor

    πŸ€– Generated by the Agentic Engineer

    The grace-period measurement is answered from the controller's source β€” no healthy fleet required

    This issue is blocked on measuring whether Healthy's lastTransitionTime on a Provider is a real transition timestamp or is rewritten every reconcile. That question is decided by crossplane-runtime, and the answer is unambiguous: it is a real transition timestamp, so a grace period keyed on it is reachable.

    Condition.Equal deliberately excludes LastTransitionTime from the comparison β€” the doc comment says so outright:

    // Equal returns true if the condition is identical to the supplied condition,
    // ignoring the LastTransitionTime.  If one or both conditions have not
    // provided the ObservedGeneration it is not considered in the comparison.
    func (c Condition) Equal(other Condition) bool {
    	// Type, Status, Reason, Message (+ ObservedGeneration when both are non-zero)
    }

    and SetConditions skips the write entirely when Equal holds, leaving the stored condition β€” and therefore its original LastTransitionTime β€” in place:

    for i, existing := range s.Conditions {
    	if existing.Type != cond.Type { continue }
    	if existing.Equal(cond) { exists = true; continue }   // <- preserved, not restamped
    	s.Conditions[i] = cond
    	exists = true
    }

    Verified identical in both crossplane-runtime/v2@v2.1.0 and v2@v2.2.1 (v2.2.1 only adds the zero-guard on ObservedGeneration). The condition constructors do stamp metav1.Now(), but that value is discarded whenever Equal is 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 (resourceVersion was 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's Synced "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. If Synced really does move per reconcile, the cause must be that its Message varies between reconciles, which makes Equal false and replaces the whole condition.

    That reframing matters for this check's design, and it generalises better than the original warning:

    • The risk is not the kind or the condition type; it is message instability. The safe test for any condition is "do Reason and Message hold steady while the object is actively being written".
    • Healthy=True on a Provider is 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/Message are 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 blocked state on the grace-period question, build the check against Healthy's lastTransitionTime as originally designed, and keep the narrowed message-stability confirmation as an acceptance check before the alert is armed.

  4. moved this from 🧊 Icebox to 🫴 Ready in 🌊 Project Boardon Oct 17, 2026
  5. updated Effort Low and Priority High on Oct 17, 2026
  6. self-assigned this
    on Oct 17, 2026
  7. moved this from 🫴 Ready to πŸƒπŸ»β€β™‚οΈ In Progress in 🌊 Project Boardon Oct 17, 2026
  8. added 2 commits that reference this issue on Oct 17, 2026
  9. moved this from πŸƒπŸ»β€β™‚οΈ In Progress to πŸ‘€ In Review in 🌊 Project Boardon Oct 17, 2026
  10. moved this from πŸ‘€ In Review to βœ… Done in 🌊 Project Boardon Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Fields

Priority

High

Start date

None yet

Target date

None yet

Effort

Low

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions