Skip to content

fix(alerting): name degraded ArgoCD app + repair 3 NoData-broken rules - #29

Merged
aatchison merged 3 commits into
mainfrom
feat/codify-grafana-alerting
Jun 29, 2026
Merged

aatchison merged 3 commits into
mainfrom
feat/codify-grafana-alerting

Conversation

@aatchison

@aatchison aatchison commented Jun 27, 2026 •

Copy link
Copy Markdown
Contributor

What

Two related alerting changes, both driven by reviewing the live firing/NoData alert state on shared01.

1. Name the degraded app in ArgoCDApplicationDegraded

The page collapsed argocd_app_info{...health_status="Degraded"} to a single scalar via sum(...), so it could only say "an app is Degraded" — never which one (confirmed live: the alert instance carried no app label).

  • alerting-catalog-paging.tf — query sum(...) → sum by (name) (...) (one alert instance per degraded app); summary/description template {{ $labels.name }}.
  • alerting.tf — new notification-policy route above the generic severity=page route, matching alertname=ArgoCDApplicationDegraded and grouping by ["alertname","name"] → one PagerDuty incident per degraded app, each auto-resolving independently.

2. Repair 3 genuinely-broken rules (found while auditing NoData state)

Most "NoData" alerts are healthy-by-design (count(<bad-condition>) is empty when nothing is wrong — verified the base metrics exist, so they still fire on real conditions). Three were actually broken. All three replacement expressions were validated against live VictoriaMetrics (each returns one series = 0 while healthy):

  • TailscaleOperatorDown — was firing permanently. sum(up{namespace="tailscale", job=~".*tailscale-operator.*"}) matches nothing (the operator isn't scraped; no up series on any cluster) → empty → NoData → no_data_state=Alerting. Rewritten to 1 - max(kube_deployment_status_replicas_available{cluster="mzla-eks-shared01", namespace="tailscale", deployment="operator"}) (continuously scraped; NoData only if the Deployment is deleted → still Alerting).
  • TailscaleOAuthSecretSyncErrors — silently blind. Wrong metric name (external_secrets_sync_calls_error_total doesn't exist) and ESO labels the target ns as exported_namespace, not namespace. Repointed to externalsecret_status_condition{exported_namespace="tailscale", name="operator-oauth", condition="Ready", status="False"}.
  • EUC1CertNotReady — could never fire (logic bug): {condition="True"} == 0 emits value 0 when not-ready, which never crosses the gt 0 threshold. Switched to {condition="False"} (1 when not Ready, 0 when Ready); no_data_state NoData → OK.
  • EUC1CertExpiringIn14Days — no_data_state NoData → OK so the healthy state reads Normal (expr already returns a value within the 14-day window).

Verification

Atlantis plan should show only in-place grafana_rule_group / grafana_notification_policy updates — 0 add / 0 destroy.

Notes / follow-up

  • Broader Grafana alerting (rule groups, PD contact points, root notification policy) was already fully codified in terraform/alerting*.tf.
  • The thundermail spike-monitor that triggered the original Degraded page is a transient-false-page source, filed separately: thunderbird/platform-infrastructure#611.

🤖 Generated with Claude Code

The alert collapsed `argocd_app_info{...health_status="Degraded"}` to a
single scalar via `sum(...)`, so a page could only say "an app is
Degraded" with no way to tell which one.

- Query: `sum(...)` -> `sum by (name) (...)` so the rule fires one alert
  instance per degraded app, carrying the `name` label.
- Annotations: summary/description template `{{ $labels.name }}`.
- Notification policy: add a route (above the generic severity=page route)
  matching alertname=ArgoCDApplicationDegraded, grouping by
  ["alertname","name"] -> one PagerDuty incident per degraded app, each
  auto-resolving independently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@aatchison
aatchison requested a review from a team as a code owner June 27, 2026 20:08
terraform fmt -check flagged the inline comment splitting the model{}
'=' alignment group; relocate it above the data{} block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mzla-atlantis

mzla-atlantis Bot commented Jun 27, 2026

Copy link
Copy Markdown

Ran Plan for project: grafana dir: terraform workspace: default

Show Output
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
~ update in-place

Terraform will perform the following actions:

  # grafana_notification_policy.root will be updated in-place
~ resource "grafana_notification_policy" "root" {
        id                 = "0:policy"
        # (7 unchanged attributes hidden)

      ~ policy {
          ~ group_by        = [
                "alertname",
              + "name",
            ]
            # (7 unchanged attributes hidden)

          - matcher {
              - label = "severity" -> null
              - match = "=~" -> null
              - value = "page|critical" -> null
            }
          + matcher {
              + label = "alertname"
              + match = "="
              + value = "ArgoCDApplicationDegraded"
            }
        }
      ~ policy {
          ~ contact_point   = "pagerduty-platform-infra-low" -> "pagerduty-platform-infra"
            # (7 unchanged attributes hidden)

          - matcher {
              - label = "severity" -> null
              - match = "=" -> null
              - value = "warning" -> null
            }
          + matcher {
              + label = "severity"
              + match = "=~"
              + value = "page|critical"
            }
        }
      ~ policy {
            # (8 unchanged attributes hidden)

          - matcher {
              - label = "severity" -> null
              - match = "=" -> null
              - value = "ticket" -> null
            }
          + matcher {
              + label = "severity"
              + match = "="
              + value = "warning"
            }
        }
      + policy {
          + contact_point = "pagerduty-platform-infra-low"
          + continue      = false
          + group_by      = [
              + "alertname",
            ]

          + matcher {
              + label = "severity"
              + match = "="
              + value = "ticket"
            }
        }
    }

  # grafana_rule_group.catalog_argocd will be updated in-place
~ resource "grafana_rule_group" "catalog_argocd" {
        id                 = "0:efhbgu7k4a2o0b:catalog-argocd-paging"
        name               = "catalog-argocd-paging"
        # (4 unchanged attributes hidden)

      ~ rule {
          ~ annotations                     = {
              ~ "description" = "An ArgoCD app has been health_status=Degraded for 15m — a tracked resource failed its runtime health check (crash-loop, failed readiness, stuck PVC) even if Git-synced. Sustained >15m usually means a real managed service (Traefik/Keycloak/vmauth) is broken. Matches only Degraded (excludes transient Progressing/Missing)." -> "ArgoCD application {{ $labels.name }} has been health_status=Degraded for 15m — a tracked resource failed its runtime health check (crash-loop, failed readiness, stuck PVC) even if Git-synced. Sustained >15m usually means a real managed service (Traefik/Keycloak/vmauth) is broken. Matches only Degraded (excludes transient Progressing/Missing)."
              ~ "summary"     = "An ArgoCD application is Degraded on shared01" -> "ArgoCD application {{ $labels.name }} is Degraded on shared01"
                # (1 unchanged element hidden)
            }
            name                            = "ArgoCDApplicationDegraded"
            # (9 unchanged attributes hidden)

          ~ data {
              ~ model          = jsonencode(
                  ~ {
                      ~ expr       = "sum(argocd_app_info{cluster=\"mzla-eks-shared01\",health_status=\"Degraded\"})" -> "sum by (name) (argocd_app_info{cluster=\"mzla-eks-shared01\",health_status=\"Degraded\"})"
                        # (3 unchanged attributes hidden)
                    }
                )
                # (3 unchanged attributes hidden)

                # (1 unchanged block hidden)
            }

            # (2 unchanged blocks hidden)
        }

        # (1 unchanged block hidden)
    }

Plan: 0 to add, 2 to change, 0 to destroy.
  • ▶️ To apply this plan, comment:
    atlantis apply -p grafana
  • 🚮 To delete this plan and lock, click here
  • 🔁 To plan this project again, comment:
    atlantis plan -p grafana

Plan: 0 to add, 2 to change, 0 to destroy.


  • ⏩ To apply all unapplied plans from this Pull Request, comment:
    atlantis apply
  • 🚮 To delete all plans and locks from this Pull Request, comment:
    atlantis unlock

1 similar comment
@mzla-atlantis

mzla-atlantis Bot commented Jun 27, 2026

Copy link
Copy Markdown

Ran Plan for project: grafana dir: terraform workspace: default

Show Output
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
~ update in-place

Terraform will perform the following actions:

  # grafana_notification_policy.root will be updated in-place
~ resource "grafana_notification_policy" "root" {
        id                 = "0:policy"
        # (7 unchanged attributes hidden)

      ~ policy {
          ~ group_by        = [
                "alertname",
              + "name",
            ]
            # (7 unchanged attributes hidden)

          - matcher {
              - label = "severity" -> null
              - match = "=~" -> null
              - value = "page|critical" -> null
            }
          + matcher {
              + label = "alertname"
              + match = "="
              + value = "ArgoCDApplicationDegraded"
            }
        }
      ~ policy {
          ~ contact_point   = "pagerduty-platform-infra-low" -> "pagerduty-platform-infra"
            # (7 unchanged attributes hidden)

          - matcher {
              - label = "severity" -> null
              - match = "=" -> null
              - value = "warning" -> null
            }
          + matcher {
              + label = "severity"
              + match = "=~"
              + value = "page|critical"
            }
        }
      ~ policy {
            # (8 unchanged attributes hidden)

          - matcher {
              - label = "severity" -> null
              - match = "=" -> null
              - value = "ticket" -> null
            }
          + matcher {
              + label = "severity"
              + match = "="
              + value = "warning"
            }
        }
      + policy {
          + contact_point = "pagerduty-platform-infra-low"
          + continue      = false
          + group_by      = [
              + "alertname",
            ]

          + matcher {
              + label = "severity"
              + match = "="
              + value = "ticket"
            }
        }
    }

  # grafana_rule_group.catalog_argocd will be updated in-place
~ resource "grafana_rule_group" "catalog_argocd" {
        id                 = "0:efhbgu7k4a2o0b:catalog-argocd-paging"
        name               = "catalog-argocd-paging"
        # (4 unchanged attributes hidden)

      ~ rule {
          ~ annotations                     = {
              ~ "description" = "An ArgoCD app has been health_status=Degraded for 15m — a tracked resource failed its runtime health check (crash-loop, failed readiness, stuck PVC) even if Git-synced. Sustained >15m usually means a real managed service (Traefik/Keycloak/vmauth) is broken. Matches only Degraded (excludes transient Progressing/Missing)." -> "ArgoCD application {{ $labels.name }} has been health_status=Degraded for 15m — a tracked resource failed its runtime health check (crash-loop, failed readiness, stuck PVC) even if Git-synced. Sustained >15m usually means a real managed service (Traefik/Keycloak/vmauth) is broken. Matches only Degraded (excludes transient Progressing/Missing)."
              ~ "summary"     = "An ArgoCD application is Degraded on shared01" -> "ArgoCD application {{ $labels.name }} is Degraded on shared01"
                # (1 unchanged element hidden)
            }
            name                            = "ArgoCDApplicationDegraded"
            # (9 unchanged attributes hidden)

          ~ data {
              ~ model          = jsonencode(
                  ~ {
                      ~ expr       = "sum(argocd_app_info{cluster=\"mzla-eks-shared01\",health_status=\"Degraded\"})" -> "sum by (name) (argocd_app_info{cluster=\"mzla-eks-shared01\",health_status=\"Degraded\"})"
                        # (3 unchanged attributes hidden)
                    }
                )
                # (3 unchanged attributes hidden)

                # (1 unchanged block hidden)
            }

            # (2 unchanged blocks hidden)
        }

        # (1 unchanged block hidden)
    }

Plan: 0 to add, 2 to change, 0 to destroy.
  • ▶️ To apply this plan, comment:
    atlantis apply -p grafana
  • 🚮 To delete this plan and lock, click here
  • 🔁 To plan this project again, comment:
    atlantis plan -p grafana

Plan: 0 to add, 2 to change, 0 to destroy.


  • ⏩ To apply all unapplied plans from this Pull Request, comment:
    atlantis apply
  • 🚮 To delete all plans and locks from this Pull Request, comment:
    atlantis unlock

@aatchison
aatchison requested a review from mzeier June 27, 2026 20:11
… cert)

Investigation of the NoData/spurious alerts surfaced three genuinely broken
rules (the kube/keycloak count-based rules are healthy-by-design — empty =
nothing wrong — and were left unchanged). All three new expressions were
validated against live VictoriaMetrics (each returns one series = 0 healthy).

- TailscaleOperatorDown: was firing permanently. `sum(up{namespace="tailscale",
  job=~".*tailscale-operator.*"})` matches nothing — the operator isn't scraped,
  so there is no `up` series on any cluster; the empty result became
  NoData -> no_data_state=Alerting. Rewrite to
  `1 - max(kube_deployment_status_replicas_available{cluster="mzla-eks-shared01",
  namespace="tailscale", deployment="operator"})` (continuously scraped; 0 when
  healthy, 1 when down, NoData only if the Deployment is deleted -> Alerting).

- TailscaleOAuthSecretSyncErrors: silently blind. Metric name was wrong
  (`external_secrets_sync_calls_error_total` doesn't exist; real name is
  `externalsecret_sync_calls_error`) and ESO labels the target ns as
  `exported_namespace`, not `namespace`. Repoint to the continuously-present
  `externalsecret_status_condition{exported_namespace="tailscale",
  name="operator-oauth", condition="Ready", status="False"}` (Ready=False signal).

- EUC1CertNotReady: could never fire (logic bug) AND showed NoData when healthy.
  `{condition="True"} == 0` emits value 0 when not-ready, which never crosses the
  gt-0 threshold. Switch to `{condition="False"}` (1 when not Ready, 0 when Ready)
  and no_data_state NoData -> OK.

- EUC1CertExpiringIn14Days: healthy state showed NoData; no_data_state -> OK
  (expr already returns a value within the 14-day window).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@aatchison aatchison changed the title feat(alerting): name the degraded app in ArgoCDApplicationDegraded page fix(alerting): name degraded ArgoCD app + repair 3 NoData-broken rules Jun 29, 2026
@mzla-atlantis

mzla-atlantis Bot commented Jun 29, 2026

Copy link
Copy Markdown

Ran Plan for project: grafana dir: terraform workspace: default

Show Output
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
~ update in-place

Terraform will perform the following actions:

  # grafana_notification_policy.root will be updated in-place
~ resource "grafana_notification_policy" "root" {
        id                 = "0:policy"
        # (7 unchanged attributes hidden)

      ~ policy {
          ~ group_by        = [
                "alertname",
              + "name",
            ]
            # (7 unchanged attributes hidden)

          - matcher {
              - label = "severity" -> null
              - match = "=~" -> null
              - value = "page|critical" -> null
            }
          + matcher {
              + label = "alertname"
              + match = "="
              + value = "ArgoCDApplicationDegraded"
            }
        }
      ~ policy {
          ~ contact_point   = "pagerduty-platform-infra-low" -> "pagerduty-platform-infra"
            # (7 unchanged attributes hidden)

          - matcher {
              - label = "severity" -> null
              - match = "=" -> null
              - value = "warning" -> null
            }
          + matcher {
              + label = "severity"
              + match = "=~"
              + value = "page|critical"
            }
        }
      ~ policy {
            # (8 unchanged attributes hidden)

          - matcher {
              - label = "severity" -> null
              - match = "=" -> null
              - value = "ticket" -> null
            }
          + matcher {
              + label = "severity"
              + match = "="
              + value = "warning"
            }
        }
      + policy {
          + contact_point = "pagerduty-platform-infra-low"
          + continue      = false
          + group_by      = [
              + "alertname",
            ]

          + matcher {
              + label = "severity"
              + match = "="
              + value = "ticket"
            }
        }
    }

  # grafana_rule_group.catalog_argocd will be updated in-place
~ resource "grafana_rule_group" "catalog_argocd" {
        id                 = "0:efhbgu7k4a2o0b:catalog-argocd-paging"
        name               = "catalog-argocd-paging"
        # (4 unchanged attributes hidden)

      ~ rule {
          ~ annotations                     = {
              ~ "description" = "An ArgoCD app has been health_status=Degraded for 15m — a tracked resource failed its runtime health check (crash-loop, failed readiness, stuck PVC) even if Git-synced. Sustained >15m usually means a real managed service (Traefik/Keycloak/vmauth) is broken. Matches only Degraded (excludes transient Progressing/Missing)." -> "ArgoCD application {{ $labels.name }} has been health_status=Degraded for 15m — a tracked resource failed its runtime health check (crash-loop, failed readiness, stuck PVC) even if Git-synced. Sustained >15m usually means a real managed service (Traefik/Keycloak/vmauth) is broken. Matches only Degraded (excludes transient Progressing/Missing)."
              ~ "summary"     = "An ArgoCD application is Degraded on shared01" -> "ArgoCD application {{ $labels.name }} is Degraded on shared01"
                # (1 unchanged element hidden)
            }
            name                            = "ArgoCDApplicationDegraded"
            # (9 unchanged attributes hidden)

          ~ data {
              ~ model          = jsonencode(
                  ~ {
                      ~ expr       = "sum(argocd_app_info{cluster=\"mzla-eks-shared01\",health_status=\"Degraded\"})" -> "sum by (name) (argocd_app_info{cluster=\"mzla-eks-shared01\",health_status=\"Degraded\"})"
                        # (3 unchanged attributes hidden)
                    }
                )
                # (3 unchanged attributes hidden)

                # (1 unchanged block hidden)
            }

            # (2 unchanged blocks hidden)
        }

        # (1 unchanged block hidden)
    }

  # grafana_rule_group.euc1_cert_expiry will be updated in-place
~ resource "grafana_rule_group" "euc1_cert_expiry" {
        id                 = "0:bfhbgu9i6fjswa:cert-expiry-euc1"
        name               = "cert-expiry-euc1"
        # (4 unchanged attributes hidden)

      ~ rule {
            name                            = "EUC1CertExpiringIn14Days"
          ~ no_data_state                   = "NoData" -> "OK"
            # (9 unchanged attributes hidden)

            # (3 unchanged blocks hidden)
        }
      ~ rule {
            name                            = "EUC1CertNotReady"
          ~ no_data_state                   = "NoData" -> "OK"
            # (9 unchanged attributes hidden)

          ~ data {
              ~ model          = jsonencode(
                  ~ {
                      ~ expr       = "certmanager_certificate_ready_status{namespace=\"keycloak\", name=\"keycloak-tls\", condition=\"True\"} == 0" -> "certmanager_certificate_ready_status{namespace=\"keycloak\", name=\"keycloak-tls\", condition=\"False\"}"
                        # (4 unchanged attributes hidden)
                    }
                )
                # (3 unchanged attributes hidden)

                # (1 unchanged block hidden)
            }

            # (2 unchanged blocks hidden)
        }
    }

  # grafana_rule_group.tailscale will be updated in-place
~ resource "grafana_rule_group" "tailscale" {
        id                 = "0:bfhbgu9i6fjswa:tailscale-operator"
        name               = "tailscale-operator"
        # (4 unchanged attributes hidden)

      ~ rule {
          ~ annotations                     = {
              ~ "description" = "The Tailscale operator is reporting fewer than 1 up target in the tailscale namespace on shared01. Tailnet admin access (operator-managed proxies and ingress) may be impaired. Check the tailscale-operator deployment in the tailscale namespace." -> "The tailscale-operator Deployment on shared01 has <1 available replica. Tailnet admin access (operator-managed proxies and ingress) may be impaired. Check: kubectl -n tailscale get deploy operator; kubectl -n tailscale describe deploy operator."
              ~ "summary"     = "Tailscale operator pod down >5m on shared01" -> "Tailscale operator down >5m on shared01"
            }
            name                            = "TailscaleOperatorDown"
            # (9 unchanged attributes hidden)

          ~ data {
              ~ model          = jsonencode(
                  ~ {
                      ~ expr       = "sum(up{namespace=\"tailscale\", job=~\".*tailscale-operator.*\"}) < 1" -> "1 - max(kube_deployment_status_replicas_available{cluster=\"mzla-eks-shared01\", namespace=\"tailscale\", deployment=\"operator\"})"
                        # (4 unchanged attributes hidden)
                    }
                )
                # (3 unchanged attributes hidden)

                # (1 unchanged block hidden)
            }

            # (2 unchanged blocks hidden)
        }
      ~ rule {
          ~ annotations                     = {
              ~ "description" = "The operator-oauth ExternalSecret in the tailscale namespace is returning sync errors (external_secrets_sync_calls_error_total rate > 0 over 15m). If the OAuth client secret goes stale the Tailscale operator loses tailnet auth. Check the ExternalSecret and its SecretStore in the tailscale namespace." -> "The operator-oauth ExternalSecret (tailscale namespace) has Ready=False, so ESO is not reconciling the OAuth client secret. If the secret goes stale the Tailscale operator loses tailnet auth. Check: kubectl -n tailscale describe externalsecret operator-oauth and its SecretStore."
              ~ "summary"     = "operator-oauth ExternalSecret returning sync errors" -> "operator-oauth ExternalSecret not Ready in tailscale namespace"
            }
            name                            = "TailscaleOAuthSecretSyncErrors"
            # (9 unchanged attributes hidden)

          ~ data {
              ~ model          = jsonencode(
                  ~ {
                      ~ expr       = "rate(external_secrets_sync_calls_error_total{namespace=\"tailscale\", name=\"operator-oauth\"}[15m]) > 0" -> "externalsecret_status_condition{exported_namespace=\"tailscale\", name=\"operator-oauth\", condition=\"Ready\", status=\"False\"}"
                        # (4 unchanged attributes hidden)
                    }
                )
                # (3 unchanged attributes hidden)

                # (1 unchanged block hidden)
            }

            # (2 unchanged blocks hidden)
        }

        # (1 unchanged block hidden)
    }

Plan: 0 to add, 4 to change, 0 to destroy.
  • ▶️ To apply this plan, comment:
    atlantis apply -p grafana
  • 🚮 To delete this plan and lock, click here
  • 🔁 To plan this project again, comment:
    atlantis plan -p grafana

Plan: 0 to add, 4 to change, 0 to destroy.


  • ⏩ To apply all unapplied plans from this Pull Request, comment:
    atlantis apply
  • 🚮 To delete all plans and locks from this Pull Request, comment:
    atlantis unlock

@aatchison

Copy link
Copy Markdown
Contributor Author

atlantis apply

@mzla-atlantis

mzla-atlantis Bot commented Jun 29, 2026

Copy link
Copy Markdown

Ran Apply for project: grafana dir: terraform workspace: default

Show Output
╷
│ Warning: Deprecated Parameter
│ 
│ The parameter "dynamodb_table" is deprecated. Use parameter "use_lockfile"
│ instead.
╵
grafana_rule_group.euc1_cert_expiry: Modifying... [id=0:bfhbgu9i6fjswa:cert-expiry-euc1]
grafana_rule_group.catalog_argocd: Modifying... [id=0:efhbgu7k4a2o0b:catalog-argocd-paging]
grafana_rule_group.tailscale: Modifying... [id=0:bfhbgu9i6fjswa:tailscale-operator]
grafana_notification_policy.root: Modifying... [id=0:policy]
grafana_notification_policy.root: Modifications complete after 1s [id=0:policy]
grafana_rule_group.tailscale: Modifications complete after 1s [id=1:bfhbgu9i6fjswa:tailscale-operator]
grafana_rule_group.catalog_argocd: Modifications complete after 1s [id=1:efhbgu7k4a2o0b:catalog-argocd-paging]
grafana_rule_group.euc1_cert_expiry: Modifications complete after 1s [id=1:bfhbgu9i6fjswa:cert-expiry-euc1]

Apply complete! Resources: 0 added, 4 changed, 0 destroyed.

@aatchison
aatchison merged commit 76d0346 into main Jun 29, 2026
8 checks passed
@mzla-atlantis

mzla-atlantis Bot commented Jun 29, 2026

Copy link
Copy Markdown

Locks and plans deleted for the projects and workspaces modified in this pull request:

  • dir: terraform workspace: default

@aatchison
aatchison deleted the feat/codify-grafana-alerting branch June 29, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants