feat(obs): notify on Flux deploy start and completion
gate / gate (pull_request) Successful in 1m40s
gate / conformance (pull_request) Has been skipped

The operator got no mail when Flux began applying a git change and none when
it finished. Measured cause: notification-controller v1.4.0 is running and its
CRDs serve v1beta3, but there are ZERO Provider and ZERO Alert objects in the
cluster — nothing in git ever created any.

The obvious fix does not work. Neither Flux event can be filtered into a deploy
signal: ReconciliationSucceeded is a heartbeat (101 events in one hour across 16
Kustomizations, message byte-identical whether the apply changed anything), and
Progressing is worse (five Kustomizations re-apply the same object on every tick
— event counts 2505/2504/2501/2495/2490). gotk_* carries no revision label at
all (gotk_reconcile_condition 120 series, gotk_suspend_status 40, none with one).

The applied revision is the only signal that moves when and only when git moves,
and it lives in object status alone. So export it via the kube-state-metrics
custom-resource-state path that already serves the Velero Schedule metrics —
kube_flux_kustomization_info/_suspended and kube_flux_gitrepository_info — and
ask the question in PromQL:

  FluxRolloutInProgress (severity: info, no `for:`) fires the moment a
  non-suspended Kustomization's applied revision is not the revision the
  GitRepository fetched from main, and CLEARS when all have caught up. Fired
  mail = a deploy started; RESOLVED mail = it completed. The completion half is
  send_resolved, not a second rule.

  FluxRolloutStuck (severity: warning, for: 30m) is the same expression as the
  escalation for a rollout that started and never converged.

Two guards, each with a test whose only job is to fail if the clause is removed:
`and on () (count(gitrepository_info) > 0)` — without it a missing exporter makes
`unless on (revision) <empty>` remove nothing and all 16 Kustomizations alert
forever, the permanently-firing mode this repo shipped once already — and the
suspended-Kustomization exclusion, without which a suspended tier holds the
rollout open and the "applied" mail never arrives.

A dedicated Alertmanager severity=info route + email-deploy receiver keeps deploy
notices off the [WARNING] catch-all. podAnnotations carries a roll-counter because
the chart sets no checksum over the custom-resource-state ConfigMap, so a
config-only edit would leave KSM on the old collector set with the new alerts
silently inert.

Verified: promtool SUCCESS on 6 scenarios (both guards, plus the convergence test
that proves the alert empties itself); lint/l0/render-golden/l1/decisions-log/
spec-immutability green in the pinned toolbox. NOT verified: no mail has been
sent — the metrics do not exist until this merges. Full gap list, and the
deliberately-cut notification-controller half, in the handoff entry.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
jakman85andClaude Opus 5 committed 2026-09-29 23:40:01 -05:00
1 parent b93594b7c8
commit 55b232e2ce
7 files changed
+789

No files matched your search

+85
View File
@@ -799,6 +799,91 @@ spec:
summary: "Flux resource suspended: {{ $labels.kind }}/{{ $labels.name }}"
description: "{{ $labels.kind }} {{ $labels.namespace }}/{{ $labels.name }} has been suspended for 1h — reconciliation/drift-correction is off."
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-suspended"
# ===================================================================================
# DEPLOY NOTIFICATIONS (added 2026-09-29). The only rules in this repo whose PURPOSE is to
# send mail on the HEALTHY path: the operator asked to be told when Flux starts applying a
# git change and when it finishes. Read the two together — they share one expression and
# differ only in `for` and severity.
#
# WHY A RULE AND NOT A FLUX Alert/Provider. Neither Flux event can be filtered into a
# deploy signal. Measured on prod 2026-09-29: a Kustomization emits ReconciliationSucceeded
# on EVERY interval tick (101 events in one hour across 16 Kustomizations) with the message
# "Reconciliation finished in 324ms, next run in 10m0s" — byte-identical whether the apply
# changed anything or not; and `Progressing` is worse, because five Kustomizations re-apply
# the same object on every tick (event counts 2505/2504/2501/2495/2490), so anything keyed
# on it mails five times every ten minutes forever. The applied REVISION is the only signal
# that moves when and only when git moves. See the kube_flux_* block in
# kube-prometheus-stack.yaml for how it reaches the TSDB.
#
# THE EXPRESSION. `unless on (revision)` keeps a Kustomization only while its applied
# revision is absent from the set of revisions the GitRepository has fetched — i.e. while it
# is behind main. It empties itself as each Kustomization catches up, which is what makes
# the RESOLVED notification mean "the rollout completed" rather than "the alert timed out".
# Alertmanager's email-deploy receiver carries send_resolved: true for exactly that reason,
# and group_by: [alertname] collapses all 16 Kustomizations into one mail per rollout.
#
# TWO GUARDS, BOTH LOAD-BEARING, BOTH UNIT-TESTED (tests/flux-rollout.test.yaml):
# * `and on () (count(...) > 0)` — if kube_flux_gitrepository_info is ABSENT (KSM rolled
# without the ConfigMap, the flux RBAC lost, customResourceState reverted) then
# `unless on (revision) <empty>` removes NOTHING and all 16 Kustomizations alert
# forever, for no reason. That is the permanently-firing failure mode this repo already
# shipped once (CNPG, 2026-09-12). The guard makes the missing-exporter case SILENT,
# which is the correct direction for a notification — a broken exporter is what
# FluxReconciliationFailure and the platform-health.monitoring-self rules are for.
# * `unless on (namespace, name) (kube_flux_kustomization_suspended == 1)` — a suspended
# Kustomization pins an old revision BY DESIGN, and would otherwise hold a rollout open
# for as long as it stays suspended, so the resolved mail (the "it finished" half of the
# feature) would never arrive. FluxResourceSuspended above is what reports that state.
#
# `revision!=""` on both sides: KSM omits a label whose path is absent, so a Kustomization
# that has never applied anything — or a GitRepository with no artifact yet — publishes the
# series with no `revision` label. Without the matcher those group together under the empty
# string and compare EQUAL, i.e. a never-applied Kustomization would read as converged.
# ===================================================================================
- alert: FluxRolloutInProgress
expr: |
(
(
kube_flux_kustomization_info{revision!=""}
unless on (namespace, name) (kube_flux_kustomization_suspended == 1)
)
unless on (revision) kube_flux_gitrepository_info{name="flux-system", revision!=""}
)
and on () (count(kube_flux_gitrepository_info{name="flux-system", revision!=""}) > 0)
# NO `for:`, ON PURPOSE. Every other rule in this file waits, because every other rule
# reports a fault and a fault that clears in 30s is noise. This one reports an EVENT the
# operator asked to be told about, so the failure mode to avoid is the opposite: a `for:`
# of even 1m would silently swallow any rollout that converged faster than that (a manual
# `flux reconcile`, a change touching one Kustomization), and a deploy notification that
# only sometimes arrives is worse than no notification at all.
labels:
severity: info # -> the email-deploy Alertmanager route; subject "[FLUX] applying/applied"
annotations:
summary: "Flux is applying a new revision — {{ $labels.name }} is behind main"
description: "Kustomization {{ $labels.namespace }}/{{ $labels.name }} has applied {{ $labels.revision }}, which is not the revision the flux-system GitRepository has fetched from main. THIS MAIL MEANS A GIT CHANGE STARTED ROLLING OUT; the RESOLVED mail for this same alert means every Kustomization reached the new revision and the rollout is complete."
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-rollout"
# The escalation: a rollout that STARTED and never finished. Same expression, so it cannot
# disagree with the notification above about what "behind main" means; 30m is comfortably
# past the slowest legitimate convergence (the longest Kustomization interval is 1h for
# cilium, but its dependents are 10m and a wave chain converges well inside 30m).
# severity: warning, so this one takes the normal email-warning path and reads as a fault.
- alert: FluxRolloutStuck
expr: |
(
(
kube_flux_kustomization_info{revision!=""}
unless on (namespace, name) (kube_flux_kustomization_suspended == 1)
)
unless on (revision) kube_flux_gitrepository_info{name="flux-system", revision!=""}
)
and on () (count(kube_flux_gitrepository_info{name="flux-system", revision!=""}) > 0)
for: 30m
labels:
severity: warning
annotations:
summary: "Flux rollout has not completed: {{ $labels.name }} stuck on an old revision"
description: "Kustomization {{ $labels.namespace }}/{{ $labels.name }} has been behind the flux-system GitRepository revision for 30m (applied {{ $labels.revision }}) — a git change started rolling out and has not converged. Check the Kustomization's Ready condition and its dependencies."
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-rollout"
# =====================================================================================
# Certificates — cert-manager